From 7d7f7100a4caefd8a8dc0ac2bb866fde8682b4d8 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 13:10:11 -0700 Subject: [PATCH 01/13] fix(coding-agent): refresh intro colors - Recomputes styled header details when the active theme changes - Keeps theme previews and hot reloads visually consistent --- .../interactive/components/diffex-welcome.ts | 9 ++++- .../src/modes/interactive/interactive-mode.ts | 17 +++++----- .../coding-agent/test/diffex-welcome.test.ts | 33 ++++++++++++++----- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/diffex-welcome.ts b/packages/coding-agent/src/modes/interactive/components/diffex-welcome.ts index bab972fd7..30e3c9598 100644 --- a/packages/coding-agent/src/modes/interactive/components/diffex-welcome.ts +++ b/packages/coding-agent/src/modes/interactive/components/diffex-welcome.ts @@ -86,6 +86,7 @@ export class DiffexWelcomeHeader implements Component { private tick = 0; private interval: ReturnType | undefined; private readonly details = new Text("", 1, 0); + private detailsText: string | undefined; constructor(ui: TUI, getCollapsedText: () => string, getExpandedText: () => string, expanded = false) { this.ui = ui; @@ -115,6 +116,7 @@ export class DiffexWelcomeHeader implements Component { } render(width: number): string[] { + this.updateDetails(); if (width < 4) { return [...renderDiffexWelcomeFrame(this.tick, width).map(diffexIntroText), ...this.details.render(width)]; } @@ -149,6 +151,11 @@ export class DiffexWelcomeHeader implements Component { } private updateDetails(): void { - this.details.setText(this.expanded ? this.getExpandedText() : this.getCollapsedText()); + const detailsText = this.expanded ? this.getExpandedText() : this.getCollapsedText(); + if (detailsText === this.detailsText) { + return; + } + this.detailsText = detailsText; + this.details.setText(detailsText); } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8e5493750..246b1d678 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -941,16 +941,17 @@ export class InteractiveMode { // Add header with keybindings from config (unless silenced) if (this.options.verbose || !this.settingsManager.getQuietStartup()) { - const logo = diffexIntroAccent("Diffex") + diffexIntroText(` v${this.version}`); - const projectDirectory = diffexIntroText( - formatCwdForFooter(this.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE), - ); - const headerLines = [logo, projectDirectory, ...this.getStartupWhatsNewLines()]; - const headerDetails = headerLines.join("\n"); + const getHeaderDetails = () => { + const logo = diffexIntroAccent("Diffex") + diffexIntroText(` v${this.version}`); + const projectDirectory = diffexIntroText( + formatCwdForFooter(this.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE), + ); + return [logo, projectDirectory, ...this.getStartupWhatsNewLines()].join("\n"); + }; this.builtInHeader = new DiffexWelcomeHeader( this.ui, - () => headerDetails, - () => headerDetails, + getHeaderDetails, + getHeaderDetails, this.getStartupExpansionState(), ); diff --git a/packages/coding-agent/test/diffex-welcome.test.ts b/packages/coding-agent/test/diffex-welcome.test.ts index 45e1609f8..a3c31d805 100644 --- a/packages/coding-agent/test/diffex-welcome.test.ts +++ b/packages/coding-agent/test/diffex-welcome.test.ts @@ -30,14 +30,9 @@ describe("DiffexWelcomeHeader", () => { test("wraps the intro in a blue boundary box with light logo text", () => { initTheme("dark", false); const ui = { requestRender() {} } as unknown as TUI; - const details = [diffexIntroAccent("Diffex") + diffexIntroText(" v1.0.0"), diffexIntroText("~/project")].join( - "\n", - ); - const header = new DiffexWelcomeHeader( - ui, - () => details, - () => details, - ); + const getDetails = () => + [diffexIntroAccent("Diffex") + diffexIntroText(" v1.0.0"), diffexIntroText("~/project")].join("\n"); + const header = new DiffexWelcomeHeader(ui, getDetails, getDetails); header.dispose(); const rendered = header.render(60); @@ -51,4 +46,26 @@ describe("DiffexWelcomeHeader", () => { expect(visibleWidth(line)).toBe(60); } }); + + test("refreshes detail colors when the active theme changes", () => { + initTheme("dark", false); + const ui = { requestRender() {} } as unknown as TUI; + const getDetails = () => + [diffexIntroAccent("Diffex") + diffexIntroText(" v1.0.0"), diffexIntroText("~/project")].join("\n"); + const header = new DiffexWelcomeHeader(ui, getDetails, getDetails); + header.dispose(); + + const darkDetails = header.render(60).find((line) => line.includes("Diffex")); + const darkAccent = theme.getFgAnsi("startupIntroAccent"); + const darkText = theme.getFgAnsi("startupIntroText"); + expect(darkDetails).toContain(darkAccent); + expect(darkDetails).toContain(darkText); + + initTheme("light", false); + const lightDetails = header.render(60).find((line) => line.includes("Diffex")); + expect(lightDetails).toContain(theme.getFgAnsi("startupIntroAccent")); + expect(lightDetails).toContain(theme.getFgAnsi("startupIntroText")); + expect(lightDetails).not.toContain(darkAccent); + expect(lightDetails).not.toContain(darkText); + }); }); From d5624769524ee7e04da96942679e85e6f613915f Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 13:22:29 -0700 Subject: [PATCH 02/13] fix(tui): unify selection styles - Uses one blue semantic token for selector prefixes and primary selected text - Replaces divergent arrow glyphs with the shared greater-than marker --- packages/coding-agent/docs/themes.md | 15 ++++---- .../interactive/components/config-selector.ts | 10 ++++- .../components/extension-selector.ts | 14 +++++-- .../components/first-time-setup.ts | 13 +++++-- .../interactive/components/model-selector.ts | 8 ++-- .../interactive/components/oauth-selector.ts | 8 ++-- .../components/scoped-models-selector.ts | 6 ++- .../components/session-selector.ts | 4 +- .../interactive/components/tree-selector.ts | 4 +- .../interactive/components/trust-selector.ts | 13 +++++-- .../components/user-message-selector.ts | 16 ++++++-- .../src/modes/interactive/theme/dark.json | 2 +- .../src/modes/interactive/theme/light.json | 2 +- .../modes/interactive/theme/theme-schema.json | 8 +++- .../src/modes/interactive/theme/theme.ts | 30 ++++++++------- .../test/first-time-setup.test.ts | 19 ++++++++++ .../coding-agent/test/oauth-selector.test.ts | 2 + .../3217-scoped-model-order.test.ts | 8 +++- ...l-selector-filter-resets-selection.test.ts | 10 +++-- .../coding-agent/test/theme-detection.test.ts | 38 ++++++++++++++----- .../coding-agent/test/tree-selector.test.ts | 15 ++++++++ .../coding-agent/test/trust-selector.test.ts | 2 + packages/tui/src/components/select-list.ts | 6 +-- packages/tui/src/components/settings-list.ts | 3 +- packages/tui/src/index.ts | 2 + 25 files changed, 191 insertions(+), 67 deletions(-) diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index 0b838b59b..ee1c5862c 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -72,7 +72,7 @@ vim ~/.diffex/agent/themes/my-theme.json "thinkingText": "secondary", "startupIntroText": "#CDD6F4", "startupIntroAccent": "#89B4FA", - "autocompleteSelected": "#89B4FA", + "selectionAccent": "#89B4FA", "selectedBg": "#2d2d30", "scrollbarThumb": "#555566", "userMessageBg": "#2d2d30", @@ -145,21 +145,21 @@ vim ~/.diffex/agent/themes/my-theme.json - `name` is required, must be unique, and must not contain `/`. - `vars` is optional. Define reusable colors here, then reference them in `colors`. - `colors` must define all 51 required tokens. - `thinkingMax`, `scrollbarThumb`, `startupIntroText`, `startupIntroAccent`, and `autocompleteSelected` are optional compatibility tokens with fallbacks. + `thinkingMax`, `scrollbarThumb`, `startupIntroText`, `startupIntroAccent`, `selectionAccent`, and `autocompleteSelected` are optional compatibility tokens with fallbacks. The `$schema` field enables editor auto-completion and validation. ## Color Tokens Every theme must define all 51 required color tokens. -`thinkingMax`, `scrollbarThumb`, `startupIntroText`, `startupIntroAccent`, and `autocompleteSelected` are optional for compatibility with existing themes. -When omitted, they use `thinkingXhigh`, `selectedBg`, `text`, `mdLink`, and `startupIntroAccent`, respectively. +`thinkingMax`, `scrollbarThumb`, `startupIntroText`, `startupIntroAccent`, `selectionAccent`, and `autocompleteSelected` are optional for compatibility with existing themes. +When omitted, they use `thinkingXhigh`, `selectedBg`, `text`, `mdLink`, `autocompleteSelected` or `startupIntroAccent`, and `selectionAccent`, respectively. ### Core UI (11 colors) | Token | Purpose | |-------|---------| -| `accent` | Primary accent (logo, selected items, cursor) | +| `accent` | Primary accent for titles, active controls, and status elements | | `border` | Normal borders | | `borderAccent` | Highlighted borders | | `borderMuted` | Subtle borders (editor) | @@ -171,13 +171,14 @@ When omitted, they use `thinkingXhigh`, `selectedBg`, `text`, `mdLink`, and `sta | `text` | Default text (usually `""`) | | `thinkingText` | Thinking block text | -### Startup Intro And Autocomplete (3 optional) +### Startup Intro And Selection (4 optional) | Token | Purpose | |-------|---------| | `startupIntroText` | Startup logo art, version, directory, and changelog preview text; optional, falls back to `text` | | `startupIntroAccent` | Startup title and boundary box; optional, falls back to `mdLink` | -| `autocompleteSelected` | Editor autocomplete selected row; optional, falls back to `startupIntroAccent` | +| `selectionAccent` | Selected row prefix and primary text; optional, falls back to `autocompleteSelected`, then `startupIntroAccent` | +| `autocompleteSelected` | Legacy autocomplete selection color; optional, falls back to `selectionAccent` | ### Backgrounds & Content (11 required, 1 optional) diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index b8403f540..7b87136b0 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -11,8 +11,10 @@ import { getKeybindings, Input, matchesKey, + SELECTED_LIST_PREFIX, Spacer, truncateToWidth, + UNSELECTED_LIST_PREFIX, visibleWidth, } from "@earendil-works/diffex-tui"; import { CONFIG_DIR_NAME } from "../../../config.ts"; @@ -426,10 +428,14 @@ class ResourceList implements Component, Focusable { } else { // Resource item (cursor only on items) const item = entry.item; - const cursor = isSelected ? "> " : " "; + const cursor = isSelected ? theme.fg("selectionAccent", SELECTED_LIST_PREFIX) : UNSELECTED_LIST_PREFIX; const dimmed = this.isDimmedItem(item); const nameText = isSelected && !dimmed ? theme.bold(item.displayName) : item.displayName; - const name = dimmed ? theme.fg("dim", nameText) : nameText; + const name = dimmed + ? theme.fg("dim", nameText) + : isSelected + ? theme.fg("selectionAccent", nameText) + : nameText; lines.push( truncateToWidth( `${cursor} ${this.renderCheckbox(item)} ${name}${this.getItemSuffix(item)}`, diff --git a/packages/coding-agent/src/modes/interactive/components/extension-selector.ts b/packages/coding-agent/src/modes/interactive/components/extension-selector.ts index 83aeae6fa..8cba516cb 100644 --- a/packages/coding-agent/src/modes/interactive/components/extension-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-selector.ts @@ -3,7 +3,15 @@ * Displays a list of string options with keyboard navigation. */ -import { Container, getKeybindings, Spacer, Text, type TUI } from "@earendil-works/diffex-tui"; +import { + Container, + getKeybindings, + SELECTED_LIST_PREFIX, + Spacer, + Text, + type TUI, + UNSELECTED_LIST_PREFIX, +} from "@earendil-works/diffex-tui"; import { theme } from "../theme/theme.ts"; import { CountdownTimer } from "./countdown-timer.ts"; import { DynamicBorder } from "./dynamic-border.ts"; @@ -82,8 +90,8 @@ export class ExtensionSelectorComponent extends Container { for (let i = 0; i < this.options.length; i++) { const isSelected = i === this.selectedIndex; const text = isSelected - ? theme.fg("accent", "→ ") + theme.fg("accent", this.options[i]) - : ` ${theme.fg("text", this.options[i])}`; + ? theme.fg("selectionAccent", SELECTED_LIST_PREFIX) + theme.fg("selectionAccent", this.options[i]) + : `${UNSELECTED_LIST_PREFIX}${theme.fg("text", this.options[i])}`; this.listContainer.addChild(new Text(text, 1, 0)); } } diff --git a/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts b/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts index 196d0e911..39f4c07da 100644 --- a/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts +++ b/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts @@ -1,4 +1,11 @@ -import { Container, getKeybindings, Spacer, Text } from "@earendil-works/diffex-tui"; +import { + Container, + getKeybindings, + SELECTED_LIST_PREFIX, + Spacer, + Text, + UNSELECTED_LIST_PREFIX, +} from "@earendil-works/diffex-tui"; import { type TerminalTheme, theme } from "../theme/theme.ts"; import { DIFFEX_LOGO_ART } from "./diffex-welcome.ts"; import { DynamicBorder } from "./dynamic-border.ts"; @@ -99,8 +106,8 @@ export class FirstTimeSetupComponent extends Container { private addOptionList(labels: string[], selectedIndex: number): void { for (let i = 0; i < labels.length; i++) { const isSelected = i === selectedIndex; - const prefix = isSelected ? theme.fg("accent", "→ ") : " "; - const label = isSelected ? theme.fg("accent", labels[i]) : theme.fg("text", labels[i]); + const prefix = isSelected ? theme.fg("selectionAccent", SELECTED_LIST_PREFIX) : UNSELECTED_LIST_PREFIX; + const label = isSelected ? theme.fg("selectionAccent", labels[i]) : theme.fg("text", labels[i]); this.addChild(new Text(`${prefix}${label}`, 1, 0)); } } diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 81619ddfb..4f09a490b 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -5,9 +5,11 @@ import { fuzzyFilter, getKeybindings, Input, + SELECTED_LIST_PREFIX, Spacer, Text, type TUI, + UNSELECTED_LIST_PREFIX, } from "@earendil-works/diffex-tui"; import type { ModelRuntime } from "../../../core/model-runtime.ts"; import type { SettingsManager } from "../../../core/settings-manager.ts"; @@ -274,13 +276,13 @@ export class ModelSelectorComponent extends Container implements Focusable { let line = ""; if (isSelected) { - const prefix = theme.fg("accent", "→ "); + const prefix = theme.fg("selectionAccent", SELECTED_LIST_PREFIX); const modelText = `${item.id}`; const providerBadge = theme.fg("muted", `[${item.provider}]`); const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; - line = `${prefix + theme.fg("accent", modelText)} ${providerBadge}${checkmark}`; + line = `${prefix + theme.fg("selectionAccent", modelText)} ${providerBadge}${checkmark}`; } else { - const modelText = ` ${item.id}`; + const modelText = `${UNSELECTED_LIST_PREFIX}${item.id}`; const providerBadge = theme.fg("muted", `[${item.provider}]`); const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; line = `${modelText} ${providerBadge}${checkmark}`; diff --git a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts index d431716b6..37c67738e 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -5,8 +5,10 @@ import { fuzzyFilter, getKeybindings, Input, + SELECTED_LIST_PREFIX, Spacer, TruncatedText, + UNSELECTED_LIST_PREFIX, } from "@earendil-works/diffex-tui"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; @@ -133,11 +135,11 @@ export class OAuthSelectorComponent extends Container implements Focusable { : ""; let line = ""; if (isSelected) { - const prefix = theme.fg("accent", "→ "); - const text = theme.fg("accent", provider.name); + const prefix = theme.fg("selectionAccent", SELECTED_LIST_PREFIX); + const text = theme.fg("selectionAccent", provider.name); line = prefix + text + authTypeLabel + statusIndicator; } else { - const text = ` ${theme.fg("text", provider.name)}`; + const text = `${UNSELECTED_LIST_PREFIX}${theme.fg("text", provider.name)}`; line = text + authTypeLabel + statusIndicator; } diff --git a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts index 0d8850840..2a157620b 100644 --- a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts @@ -7,8 +7,10 @@ import { Input, Key, matchesKey, + SELECTED_LIST_PREFIX, Spacer, Text, + UNSELECTED_LIST_PREFIX, } from "@earendil-works/diffex-tui"; import { getModelSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; @@ -245,9 +247,9 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl for (let i = startIndex; i < endIndex; i++) { const item = this.filteredItems[i]!; const isSelected = i === this.selectedIndex; - const prefix = isSelected ? theme.fg("accent", "→ ") : " "; + const prefix = isSelected ? theme.fg("selectionAccent", SELECTED_LIST_PREFIX) : UNSELECTED_LIST_PREFIX; const id = item.model?.id ?? item.fullId; - const modelText = isSelected ? theme.fg("accent", id) : id; + const modelText = isSelected ? theme.fg("selectionAccent", id) : id; const providerBadge = theme.fg("muted", item.model ? ` [${item.model.provider}]` : " [unavailable]"); const status = item.model ? allEnabled diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index 89f2de471..1eec63c36 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -8,9 +8,11 @@ import { type Focusable, getKeybindings, Input, + SELECTED_LIST_PREFIX, Spacer, Text, truncateToWidth, + UNSELECTED_LIST_PREFIX, visibleWidth, } from "@earendil-works/diffex-tui"; import { KeybindingsManager } from "../../../core/keybindings.ts"; @@ -473,7 +475,7 @@ class SessionList implements Component, Focusable { } // Cursor - const cursor = isSelected ? theme.fg("accent", "› ") : " "; + const cursor = isSelected ? theme.fg("selectionAccent", SELECTED_LIST_PREFIX) : UNSELECTED_LIST_PREFIX; // Calculate available width for message const prefixWidth = visibleWidth(prefix); diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index d68241d9b..46703c4fb 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -5,10 +5,12 @@ import { getKeybindings, Input, type Keybinding, + SELECTED_LIST_PREFIX, Spacer, sliceByColumn, Text, truncateToWidth, + UNSELECTED_LIST_PREFIX, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/diffex-tui"; @@ -686,7 +688,7 @@ class TreeList implements Component { const isSelected = i === this.selectedIndex; // Build line: cursor + prefix + path marker + label + content - const cursor = isSelected ? theme.fg("accent", "› ") : " "; + const cursor = isSelected ? theme.fg("selectionAccent", SELECTED_LIST_PREFIX) : UNSELECTED_LIST_PREFIX; // If multiple roots, shift display (roots at 0, not 1) const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent; diff --git a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts index e439b52d4..5fd786d4b 100644 --- a/packages/coding-agent/src/modes/interactive/components/trust-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/trust-selector.ts @@ -1,4 +1,11 @@ -import { Container, getKeybindings, Spacer, Text } from "@earendil-works/diffex-tui"; +import { + Container, + getKeybindings, + SELECTED_LIST_PREFIX, + Spacer, + Text, + UNSELECTED_LIST_PREFIX, +} from "@earendil-works/diffex-tui"; import { getProjectTrustOptions, type ProjectTrustOption, @@ -108,8 +115,8 @@ export class TrustSelectorComponent extends Container { const isSelected = i === this.selectedIndex; const isCurrent = this.isSavedOption(option); const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; - const prefix = isSelected ? theme.fg("accent", "→ ") : " "; - const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label); + const prefix = isSelected ? theme.fg("selectionAccent", SELECTED_LIST_PREFIX) : UNSELECTED_LIST_PREFIX; + const label = isSelected ? theme.fg("selectionAccent", option.label) : theme.fg("text", option.label); this.listContainer.addChild(new Text(`${prefix}${label}${checkmark}`, 1, 0)); } } diff --git a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts index 5fcc266fb..bcf4566d3 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts @@ -1,4 +1,13 @@ -import { type Component, Container, getKeybindings, Spacer, Text, truncateToWidth } from "@earendil-works/diffex-tui"; +import { + type Component, + Container, + getKeybindings, + SELECTED_LIST_PREFIX, + Spacer, + Text, + truncateToWidth, + UNSELECTED_LIST_PREFIX, +} from "@earendil-works/diffex-tui"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; @@ -54,10 +63,11 @@ class UserMessageList implements Component { const normalizedMessage = message.text.replace(/\n/g, " ").trim(); // First line: cursor + message - const cursor = isSelected ? theme.fg("accent", "› ") : " "; + const cursor = isSelected ? theme.fg("selectionAccent", SELECTED_LIST_PREFIX) : UNSELECTED_LIST_PREFIX; const maxMsgWidth = width - 2; // Account for cursor (2 chars) const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth); - const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg); + const messageLine = + cursor + (isSelected ? theme.bold(theme.fg("selectionAccent", truncatedMsg)) : truncatedMsg); lines.push(messageLine); diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index 79324e80f..f474c74c0 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -35,7 +35,7 @@ "thinkingText": "gray", "startupIntroText": "startupIntroText", "startupIntroAccent": "startupIntroAccent", - "autocompleteSelected": "startupIntroAccent", + "selectionAccent": "startupIntroAccent", "selectedBg": "selectedBg", "scrollbarThumb": "selectedBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 74a5c85b3..0f796935c 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -34,7 +34,7 @@ "thinkingText": "mediumGray", "startupIntroText": "startupIntroText", "startupIntroAccent": "startupIntroAccent", - "autocompleteSelected": "startupIntroAccent", + "selectionAccent": "startupIntroAccent", "selectedBg": "selectedBg", "scrollbarThumb": "selectedBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index 3e4791f20..465825bf1 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -34,7 +34,7 @@ }, "colors": { "type": "object", - "description": "Theme color definitions (thinkingMax, scrollbarThumb, startupIntroText, startupIntroAccent, and autocompleteSelected are optional and use compatible fallbacks)", + "description": "Theme color definitions (thinkingMax, scrollbarThumb, startupIntroText, startupIntroAccent, selectionAccent, and autocompleteSelected are optional and use compatible fallbacks)", "required": [ "accent", "border", @@ -141,9 +141,13 @@ "$ref": "#/$defs/colorValue", "description": "Startup intro title and boundary color (optional, falls back to mdLink)" }, + "selectionAccent": { + "$ref": "#/$defs/colorValue", + "description": "Selected row prefix and primary text (optional, falls back to autocompleteSelected, then startupIntroAccent)" + }, "autocompleteSelected": { "$ref": "#/$defs/colorValue", - "description": "Editor autocomplete selected row foreground (optional, falls back to startupIntroAccent)" + "description": "Legacy editor autocomplete selection foreground (optional, falls back to selectionAccent)" }, "selectedBg": { "$ref": "#/$defs/colorValue", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 98ede949e..b5e1ed8d3 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -6,6 +6,7 @@ import { getCapabilities, type MarkdownTheme, type RgbColor, + SELECTED_LIST_PREFIX, type SelectListTheme, type SettingsListTheme, } from "@earendil-works/diffex-tui"; @@ -33,7 +34,7 @@ const ThemeJsonSchema = Type.Object({ name: Type.String(), vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)), colors: Type.Object({ - // Core UI (11 required, 3 optional) + // Core UI (11 required, 4 optional) accent: ColorValueSchema, border: ColorValueSchema, borderAccent: ColorValueSchema, @@ -47,6 +48,7 @@ const ThemeJsonSchema = Type.Object({ thinkingText: ColorValueSchema, startupIntroText: Type.Optional(ColorValueSchema), startupIntroAccent: Type.Optional(ColorValueSchema), + selectionAccent: Type.Optional(ColorValueSchema), autocompleteSelected: Type.Optional(ColorValueSchema), // Backgrounds & Content Text (11 required, 1 optional) selectedBg: ColorValueSchema, @@ -124,6 +126,7 @@ export type ThemeColor = | "thinkingText" | "startupIntroText" | "startupIntroAccent" + | "selectionAccent" | "autocompleteSelected" | "userMessageText" | "customMessageText" @@ -332,16 +335,19 @@ function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors scrollbarThumb: ColorValue; startupIntroText: ColorValue; startupIntroAccent: ColorValue; + selectionAccent: ColorValue; autocompleteSelected: ColorValue; } { const startupIntroAccent = colors.startupIntroAccent ?? colors.mdLink; + const selectionAccent = colors.selectionAccent ?? colors.autocompleteSelected ?? startupIntroAccent; return { ...colors, thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh, scrollbarThumb: colors.scrollbarThumb ?? colors.selectedBg, startupIntroText: colors.startupIntroText ?? colors.text, startupIntroAccent, - autocompleteSelected: colors.autocompleteSelected ?? startupIntroAccent, + selectionAccent, + autocompleteSelected: colors.autocompleteSelected ?? selectionAccent, }; } @@ -1304,8 +1310,8 @@ export function getMarkdownTheme(): MarkdownTheme { export function getSelectListTheme(): SelectListTheme { return { - selectedPrefix: (text: string) => theme.fg("accent", text), - selectedText: (text: string) => theme.fg("accent", text), + selectedPrefix: (text: string) => theme.fg("selectionAccent", text), + selectedText: (text: string) => theme.fg("selectionAccent", text), description: (text: string) => theme.fg("muted", text), scrollInfo: (text: string) => theme.fg("muted", text), noMatch: (text: string) => theme.fg("muted", text), @@ -1313,23 +1319,21 @@ export function getSelectListTheme(): SelectListTheme { } export function getEditorTheme(): EditorTheme { - const selectList = getSelectListTheme(); return { borderColor: (text: string) => theme.fg("borderMuted", text), - selectList: { - ...selectList, - selectedPrefix: (text: string) => theme.fg("autocompleteSelected", text), - selectedText: (text: string) => theme.fg("autocompleteSelected", text), - }, + selectList: getSelectListTheme(), }; } export function getSettingsListTheme(): SettingsListTheme { return { - label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text), - value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)), + label: (text: string, selected: boolean) => (selected ? theme.fg("selectionAccent", text) : text), + value: (text: string, selected: boolean) => + selected ? theme.fg("selectionAccent", text) : theme.fg("muted", text), description: (text: string) => theme.fg("dim", text), - cursor: theme.fg("accent", "→ "), + get cursor() { + return theme.fg("selectionAccent", SELECTED_LIST_PREFIX); + }, hint: (text: string) => theme.fg("dim", text), }; } diff --git a/packages/coding-agent/test/first-time-setup.test.ts b/packages/coding-agent/test/first-time-setup.test.ts index 769f8b46e..aa304f52c 100644 --- a/packages/coding-agent/test/first-time-setup.test.ts +++ b/packages/coding-agent/test/first-time-setup.test.ts @@ -5,6 +5,25 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { shouldRunFirstTimeSetup } from "../src/cli/startup-ui.ts"; import { ENV_AGENT_DIR } from "../src/config.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; +import { FirstTimeSetupComponent } from "../src/modes/interactive/components/first-time-setup.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; + +describe("FirstTimeSetupComponent", () => { + it("uses the shared greater-than selection marker", () => { + initTheme("dark", false); + const component = new FirstTimeSetupComponent({ + detectedTheme: "dark", + onThemePreview: () => {}, + onSubmit: () => {}, + onCancel: () => {}, + }); + + const output = stripAnsi(component.render(80).join("\n")); + expect(output).toContain("> Dark"); + expect(output).not.toContain("→"); + }); +}); describe("shouldRunFirstTimeSetup", () => { const originalPiExperimental = process.env.DIFFEX_EXPERIMENTAL; diff --git a/packages/coding-agent/test/oauth-selector.test.ts b/packages/coding-agent/test/oauth-selector.test.ts index 051f5a202..79458b794 100644 --- a/packages/coding-agent/test/oauth-selector.test.ts +++ b/packages/coding-agent/test/oauth-selector.test.ts @@ -80,6 +80,8 @@ describe("OAuthSelectorComponent", () => { ); const output = stripAnsi(selector.render(120).join("\n")); + expect(output).toContain("> Google"); + expect(output).not.toContain("→"); expect(output).toContain("unconfigured"); expect(output).not.toContain("✓ configured"); }); diff --git a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts index c4e751206..2b43c2217 100644 --- a/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts +++ b/packages/coding-agent/test/suite/regressions/3217-scoped-model-order.test.ts @@ -1,4 +1,4 @@ -import { setKeybindings, type TUI } from "@earendil-works/diffex-tui"; +import { SELECTED_LIST_PREFIX, setKeybindings, type TUI } from "@earendil-works/diffex-tui"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { KeybindingsManager } from "../../../src/core/keybindings.ts"; import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts"; @@ -95,7 +95,11 @@ describe("issue #3217 scoped model ordering", () => { .split("\n") .filter((line) => line.includes(`[${modelOne.provider}]`)); const orderedIds = renderedLines.slice(0, 3).map((line) => { - const [modelId] = line.trim().replace(/^→\s*/, "").split(" ["); + const trimmed = line.trim(); + const modelLine = trimmed.startsWith(SELECTED_LIST_PREFIX) + ? trimmed.slice(SELECTED_LIST_PREFIX.length) + : trimmed; + const [modelId] = modelLine.split(" ["); return modelId?.trim() ?? ""; }); diff --git a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts index c7de01f81..387fb29b1 100644 --- a/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts +++ b/packages/coding-agent/test/suite/regressions/7209-model-selector-filter-resets-selection.test.ts @@ -1,4 +1,4 @@ -import { setKeybindings, type TUI } from "@earendil-works/diffex-tui"; +import { SELECTED_LIST_PREFIX, setKeybindings, type TUI } from "@earendil-works/diffex-tui"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { KeybindingsManager } from "../../../src/core/keybindings.ts"; import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts"; @@ -10,11 +10,13 @@ function createFakeTui(): TUI { return { requestRender: () => {} } as unknown as TUI; } -/** Return the model id of the highlighted (→) row in the rendered selector. */ +/** Return the model id of the highlighted row in the rendered selector. */ function selectedModelId(rendered: string): string | undefined { - const line = rendered.split("\n").find((l) => l.startsWith("→ ")); + const line = rendered + .split("\n") + .find((candidate) => candidate.startsWith(SELECTED_LIST_PREFIX) && candidate.includes(" [")); if (!line) return undefined; - const rest = line.replace(/^→\s*/, ""); + const rest = line.slice(SELECTED_LIST_PREFIX.length); const id = rest.split(" [")[0]; return id?.trim() || undefined; } diff --git a/packages/coding-agent/test/theme-detection.test.ts b/packages/coding-agent/test/theme-detection.test.ts index 5438173fd..2c9230874 100644 --- a/packages/coding-agent/test/theme-detection.test.ts +++ b/packages/coding-agent/test/theme-detection.test.ts @@ -9,6 +9,7 @@ import { detectTerminalThemeForAuto, getEditorTheme, getSelectListTheme, + getSettingsListTheme, getThemeByName, getThemeForRgbColor, initTheme, @@ -186,30 +187,31 @@ describe("theme setting helpers", () => { }); }); -describe("editor autocomplete theme", () => { - it("uses Diffex blue for slash-command selection without changing generic selectors", () => { +describe("selection theme", () => { + it("uses Diffex blue for every shared selector", () => { setCapabilities({ images: null, trueColor: true, hyperlinks: false }); initTheme("dark", false); expect(theme.getFgAnsi("startupIntroText")).toBe("\x1b[38;2;205;214;244m"); expect(theme.getFgAnsi("startupIntroAccent")).toBe("\x1b[38;2;137;180;250m"); expect(theme.getFgAnsi("thinkingMedium")).toBe(theme.getFgAnsi("startupIntroAccent")); + expect(theme.getFgAnsi("selectionAccent")).toBe(theme.getFgAnsi("startupIntroAccent")); expect(getEditorTheme().selectList.selectedText("command")).toBe( - `${theme.getFgAnsi("autocompleteSelected")}command\x1b[39m`, + `${theme.getFgAnsi("selectionAccent")}command\x1b[39m`, ); - expect(getEditorTheme().selectList.selectedPrefix("> ")).toBe( - `${theme.getFgAnsi("autocompleteSelected")}> \x1b[39m`, - ); - expect(getSelectListTheme().selectedText("command")).toBe(`${theme.getFgAnsi("accent")}command\x1b[39m`); + expect(getEditorTheme().selectList.selectedPrefix("> ")).toBe(`${theme.getFgAnsi("selectionAccent")}> \x1b[39m`); + expect(getSelectListTheme().selectedText("command")).toBe(`${theme.getFgAnsi("selectionAccent")}command\x1b[39m`); + expect(getSettingsListTheme().cursor).toBe(`${theme.getFgAnsi("selectionAccent")}> \x1b[39m`); }); - it("falls back for legacy themes without startup intro tokens", () => { + it("falls back for themes without selection or startup intro tokens", () => { const themeJson = JSON.parse( readFileSync(new URL("../src/modes/interactive/theme/dark.json", import.meta.url), "utf8"), ) as { name: string; colors: Record }; themeJson.name = "legacy-startup-intro-theme"; delete themeJson.colors.startupIntroText; delete themeJson.colors.startupIntroAccent; + delete themeJson.colors.selectionAccent; delete themeJson.colors.autocompleteSelected; const testDir = mkdtempSync(join(tmpdir(), "diffex-startup-intro-theme-")); tempDirs.push(testDir); @@ -220,6 +222,24 @@ describe("editor autocomplete theme", () => { expect(legacyTheme.getFgAnsi("startupIntroText")).toBe(legacyTheme.getFgAnsi("text")); expect(legacyTheme.getFgAnsi("startupIntroAccent")).toBe(legacyTheme.getFgAnsi("mdLink")); - expect(legacyTheme.getFgAnsi("autocompleteSelected")).toBe(legacyTheme.getFgAnsi("startupIntroAccent")); + expect(legacyTheme.getFgAnsi("selectionAccent")).toBe(legacyTheme.getFgAnsi("startupIntroAccent")); + expect(legacyTheme.getFgAnsi("autocompleteSelected")).toBe(legacyTheme.getFgAnsi("selectionAccent")); + }); + + it("uses the legacy autocomplete color as the selection fallback", () => { + const themeJson = JSON.parse( + readFileSync(new URL("../src/modes/interactive/theme/dark.json", import.meta.url), "utf8"), + ) as { name: string; colors: Record }; + themeJson.name = "legacy-autocomplete-selection-theme"; + delete themeJson.colors.selectionAccent; + themeJson.colors.autocompleteSelected = "#123456"; + const testDir = mkdtempSync(join(tmpdir(), "diffex-legacy-selection-theme-")); + tempDirs.push(testDir); + const themePath = join(testDir, "legacy-autocomplete-selection-theme.json"); + writeFileSync(themePath, JSON.stringify(themeJson)); + + const legacyTheme = loadThemeFromPath(themePath, "truecolor"); + + expect(legacyTheme.getFgAnsi("selectionAccent")).toBe(legacyTheme.getFgAnsi("autocompleteSelected")); }); }); diff --git a/packages/coding-agent/test/tree-selector.test.ts b/packages/coding-agent/test/tree-selector.test.ts index 3021e798c..ac348d191 100644 --- a/packages/coding-agent/test/tree-selector.test.ts +++ b/packages/coding-agent/test/tree-selector.test.ts @@ -126,6 +126,21 @@ function buildTree(entries: Array): SessionTreeNode[] { } describe("TreeSelectorComponent", () => { + test("uses the shared greater-than selection marker", () => { + const tree = buildTree([userMessage("user-1", null, "hello")]); + const selector = new TreeSelectorComponent( + tree, + "user-1", + 24, + () => {}, + () => {}, + ); + + const rendered = selector.getTreeList().render(80).map(stripVTControlCharacters).join("\n"); + expect(rendered).toContain("> "); + expect(rendered).not.toContain("›"); + }); + describe("initial selection with metadata entries", () => { test("focuses nearest visible ancestor when currentLeafId is a model_change with sibling branch", () => { // Tree structure: diff --git a/packages/coding-agent/test/trust-selector.test.ts b/packages/coding-agent/test/trust-selector.test.ts index 315af119f..842c0427a 100644 --- a/packages/coding-agent/test/trust-selector.test.ts +++ b/packages/coding-agent/test/trust-selector.test.ts @@ -27,6 +27,8 @@ describe("TrustSelectorComponent", () => { expect(output).toContain("Saved decision: trusted (/project)"); expect(output).toContain("Current session: trusted"); + expect(output).toContain("> Trust ✓"); + expect(output).not.toContain("→"); expect(output).toContain("Trust ✓"); expect(output).not.toContain("Do not trust ✓"); }); diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index 931f7f262..41168face 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -5,8 +5,8 @@ import { truncateToWidth, visibleWidth } from "../utils.ts"; const DEFAULT_PRIMARY_COLUMN_WIDTH = 32; const PRIMARY_COLUMN_GAP = 2; const MIN_DESCRIPTION_WIDTH = 10; -const SELECTED_PREFIX = "> "; -const UNSELECTED_PREFIX = " "; +export const SELECTED_LIST_PREFIX = "> "; +export const UNSELECTED_LIST_PREFIX = " "; const normalizeToSingleLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim(); const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max)); @@ -138,7 +138,7 @@ export class SelectList implements Component { descriptionSingleLine: string | undefined, primaryColumnWidth: number, ): string { - const prefix = isSelected ? SELECTED_PREFIX : UNSELECTED_PREFIX; + const prefix = isSelected ? SELECTED_LIST_PREFIX : UNSELECTED_LIST_PREFIX; const prefixWidth = visibleWidth(prefix); if (descriptionSingleLine && width > 40) { diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 7dce91b87..d8715395f 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -3,6 +3,7 @@ import { getKeybindings } from "../keybindings.ts"; import type { Component } from "../tui.ts"; import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; import { Input } from "./input.ts"; +import { UNSELECTED_LIST_PREFIX } from "./select-list.ts"; export interface SettingItem { /** Unique identifier for this setting */ @@ -126,7 +127,7 @@ export class SettingsList implements Component { if (!item) continue; const isSelected = i === this.selectedIndex; - const prefix = isSelected ? this.theme.cursor : " "; + const prefix = isSelected ? this.theme.cursor : UNSELECTED_LIST_PREFIX; const prefixWidth = visibleWidth(prefix); // Pad label to align values diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 00e9d414f..2462a254b 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -20,11 +20,13 @@ export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts"; export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts"; export { ScrollView, type ScrollViewOptions, type ScrollViewScrollbar } from "./components/scroll-view.ts"; export { + SELECTED_LIST_PREFIX, type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme, type SelectListTruncatePrimaryContext, + UNSELECTED_LIST_PREFIX, } from "./components/select-list.ts"; export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.ts"; export { Spacer } from "./components/spacer.ts"; From 30e4e1e4888b1c57acaf6b46bdda4847b64d0d09 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 13:29:24 -0700 Subject: [PATCH 03/13] fix(tui): show cursor on empty prompt - Keeps the typing position visible before the user enters text - Preserves placeholder styling while highlighting its first grapheme --- packages/tui/src/components/editor.ts | 6 +++- .../test/editor-placeholder-cursor.test.ts | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 packages/tui/test/editor-placeholder-cursor.test.ts diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 6aa20c5a0..fb9867666 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -556,7 +556,11 @@ export class Editor implements Component, Focusable { if (shouldShowPlaceholder) { const marker = emitCursorMarker ? CURSOR_MARKER : ""; - displayText = marker + this.placeholderStyle(sliceByColumn(this.placeholder, 0, contentWidth, true)); + const placeholder = sliceByColumn(this.placeholder, 0, contentWidth, true); + const firstGrapheme = [...this.segment(placeholder, "grapheme")][0]?.segment; + const cursorGrapheme = firstGrapheme ?? " "; + const rest = firstGrapheme ? placeholder.slice(firstGrapheme.length) : ""; + displayText = marker + this.placeholderStyle(`\x1b[7m${cursorGrapheme}\x1b[27m${rest}`); lineVisibleWidth = visibleWidth(displayText); } // Add cursor if this line has it diff --git a/packages/tui/test/editor-placeholder-cursor.test.ts b/packages/tui/test/editor-placeholder-cursor.test.ts new file mode 100644 index 000000000..0406c4f14 --- /dev/null +++ b/packages/tui/test/editor-placeholder-cursor.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { stripVTControlCharacters } from "node:util"; +import { Editor } from "../src/components/editor.ts"; +import type { TUI } from "../src/tui.ts"; +import { defaultEditorTheme } from "./test-themes.ts"; + +function createTestTui(): TUI { + return { + terminal: { rows: 24 }, + requestRender: () => {}, + } as unknown as TUI; +} + +describe("Editor placeholder cursor", () => { + it("renders the empty editor cursor over the first placeholder grapheme", () => { + const placeholder = "Ask Diffex to do something for you."; + const editor = new Editor(createTestTui(), defaultEditorTheme, { + placeholder, + placeholderStyle: (text) => `\x1b[90m${text}\x1b[39m`, + }); + + const contentLine = editor.render(80)[1]!; + + assert.ok(contentLine.startsWith("\x1b[90m\x1b[7mA\x1b[27msk Diffex")); + assert.strictEqual(stripVTControlCharacters(contentLine).trimEnd(), placeholder); + }); +}); From 9795320c7fb04390358c7e0948ae31f6485571e7 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 13:31:38 -0700 Subject: [PATCH 04/13] fix(coding-agent): update warning colors - Improves warning contrast across built-in dark and light themes - Keeps semantic warning styling consistent through the shared yellow variable --- packages/coding-agent/src/modes/interactive/theme/dark.json | 2 +- packages/coding-agent/src/modes/interactive/theme/light.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index f474c74c0..cfb0d5665 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -6,7 +6,7 @@ "blue": "#5f87ff", "green": "#b5bd68", "red": "#cc6666", - "yellow": "#ffff00", + "yellow": "#F0F04A", "text": "#d4d4d4", "gray": "#808080", "dimGray": "#666666", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 0f796935c..78d055230 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -6,7 +6,7 @@ "blue": "#547da7", "green": "#588458", "red": "#aa5555", - "yellow": "#9a7326", + "yellow": "#8F8F67", "text": "#1f2328", "mediumGray": "#6c6c6c", "dimGray": "#767676", From 1ab08970f3e5794f7426d607efe884a46d2fc4e4 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 13:43:00 -0700 Subject: [PATCH 05/13] fix(tui): clarify prompt input cursor - Blinks the empty placeholder cursor with the same reverse-video style as typed input - Keeps placeholder and wrapped user text aligned after a persistent greater-than marker --- .../src/modes/interactive/interactive-mode.ts | 3 + packages/tui/src/components/editor.ts | 55 ++++++++++++-- .../test/editor-placeholder-cursor.test.ts | 73 +++++++++++++++++-- 3 files changed, 119 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 246b1d678..0cfa11bbd 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -34,6 +34,7 @@ import { Markdown, matchesKey, ProcessTerminal, + SELECTED_LIST_PREFIX, Spacer, setKeybindings, Text, @@ -596,6 +597,8 @@ export class InteractiveMode { this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, { paddingX: editorPaddingX, autocompleteMaxVisible, + prompt: SELECTED_LIST_PREFIX, + promptStyle: (text) => theme.fg("selectionAccent", text), placeholder: "Ask Diffex to do something for you.", placeholderStyle: (text) => theme.fg("muted", text), }); diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index fb9867666..7a9053c6a 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -233,6 +233,8 @@ export interface EditorTheme { export interface EditorOptions { paddingX?: number; autocompleteMaxVisible?: number; + prompt?: string; + promptStyle?: (text: string) => string; placeholder?: string; placeholderStyle?: (text: string) => string; } @@ -243,6 +245,7 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { }; const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20; +const CURSOR_BLINK_INTERVAL_MS = 500; const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"]; function escapeCharacterClass(value: string): string { @@ -282,8 +285,12 @@ export class Editor implements Component, Focusable { protected tui: TUI; private theme: EditorTheme; private paddingX: number = 0; + private prompt: string = ""; + private promptStyle: (text: string) => string = (text) => text; private placeholder: string = ""; private placeholderStyle: (text: string) => string = (text) => text; + private cursorBlinkVisible = true; + private cursorBlinkTimer?: ReturnType; // Store last render width for cursor navigation private lastWidth: number = 80; @@ -354,6 +361,8 @@ export class Editor implements Component, Focusable { this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0; const maxVisible = options.autocompleteMaxVisible ?? 5; this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5; + this.prompt = options.prompt ?? ""; + this.promptStyle = options.promptStyle ?? ((text) => text); this.placeholder = options.placeholder ?? ""; this.placeholderStyle = options.placeholderStyle ?? ((text) => text); } @@ -423,6 +432,28 @@ export class Editor implements Component, Focusable { return this.state.lines.length === 1 && this.state.lines[0] === ""; } + private updateCursorBlink(shouldBlink: boolean): void { + if (!shouldBlink) { + if (this.cursorBlinkTimer) { + clearTimeout(this.cursorBlinkTimer); + this.cursorBlinkTimer = undefined; + } + this.cursorBlinkVisible = true; + return; + } + + if (this.cursorBlinkTimer) return; + this.cursorBlinkTimer = setTimeout(() => { + this.cursorBlinkTimer = undefined; + if (!this.focused || !this.isEditorEmpty() || this.placeholder.length === 0 || this.autocompleteState) { + this.cursorBlinkVisible = true; + return; + } + this.cursorBlinkVisible = !this.cursorBlinkVisible; + this.tui.requestRender(); + }, CURSOR_BLINK_INTERVAL_MS); + } + private isOnFirstVisualLine(): boolean { const visualLines = this.buildVisualLineMap(this.lastWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); @@ -493,7 +524,10 @@ export class Editor implements Component, Focusable { render(width: number): string[] { const maxPadding = Math.max(0, Math.floor((width - 1) / 2)); const paddingX = Math.min(this.paddingX, maxPadding); - const contentWidth = Math.max(1, width - paddingX * 2); + const innerWidth = Math.max(1, width - paddingX * 2); + const prompt = sliceByColumn(this.prompt, 0, Math.max(0, innerWidth - 1), true); + const promptWidth = visibleWidth(prompt); + const contentWidth = Math.max(1, innerWidth - promptWidth); // Layout width: with padding the cursor can overflow into it, // without padding we reserve 1 column for the cursor. @@ -546,8 +580,14 @@ export class Editor implements Component, Focusable { // hardware cursor for IME candidate-window placement even while // autocomplete (e.g. slash-command menu) is visible. const emitCursorMarker = this.focused; + this.updateCursorBlink( + this.focused && this.isEditorEmpty() && this.placeholder.length > 0 && !this.autocompleteState, + ); - for (const layoutLine of visibleLines) { + for (let visibleLineIndex = 0; visibleLineIndex < visibleLines.length; visibleLineIndex++) { + const layoutLine = visibleLines[visibleLineIndex]!; + const layoutLineIndex = this.scrollOffset + visibleLineIndex; + const linePrompt = layoutLineIndex === 0 ? this.promptStyle(prompt) : " ".repeat(promptWidth); let displayText = layoutLine.text; let lineVisibleWidth = visibleWidth(layoutLine.text); let cursorInPadding = false; @@ -560,7 +600,12 @@ export class Editor implements Component, Focusable { const firstGrapheme = [...this.segment(placeholder, "grapheme")][0]?.segment; const cursorGrapheme = firstGrapheme ?? " "; const rest = firstGrapheme ? placeholder.slice(firstGrapheme.length) : ""; - displayText = marker + this.placeholderStyle(`\x1b[7m${cursorGrapheme}\x1b[27m${rest}`); + const cursor = `\x1b[7m${cursorGrapheme}\x1b[0m`; + displayText = + marker + + (this.focused && this.cursorBlinkVisible + ? cursor + this.placeholderStyle(rest) + : this.placeholderStyle(placeholder)); lineVisibleWidth = visibleWidth(displayText); } // Add cursor if this line has it @@ -597,7 +642,7 @@ export class Editor implements Component, Focusable { const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding; // Render the line (no side borders, just horizontal lines above and below) - result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`); + result.push(`${leftPadding}${linePrompt}${displayText}${padding}${lineRightPadding}`); } // Render bottom border (with scroll indicator if more content below) @@ -615,7 +660,7 @@ export class Editor implements Component, Focusable { for (const line of autocompleteResult) { const lineWidth = visibleWidth(line); const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth)); - result.push(`${leftPadding}${line}${linePadding}${rightPadding}`); + result.push(`${leftPadding}${" ".repeat(promptWidth)}${line}${linePadding}${rightPadding}`); } } diff --git a/packages/tui/test/editor-placeholder-cursor.test.ts b/packages/tui/test/editor-placeholder-cursor.test.ts index 0406c4f14..2076da72b 100644 --- a/packages/tui/test/editor-placeholder-cursor.test.ts +++ b/packages/tui/test/editor-placeholder-cursor.test.ts @@ -1,14 +1,14 @@ import assert from "node:assert"; -import { describe, it } from "node:test"; +import { describe, it, mock } from "node:test"; import { stripVTControlCharacters } from "node:util"; import { Editor } from "../src/components/editor.ts"; -import type { TUI } from "../src/tui.ts"; +import { CURSOR_MARKER, type TUI } from "../src/tui.ts"; import { defaultEditorTheme } from "./test-themes.ts"; -function createTestTui(): TUI { +function createTestTui(requestRender: () => void = () => {}): TUI { return { terminal: { rows: 24 }, - requestRender: () => {}, + requestRender, } as unknown as TUI; } @@ -16,13 +16,72 @@ describe("Editor placeholder cursor", () => { it("renders the empty editor cursor over the first placeholder grapheme", () => { const placeholder = "Ask Diffex to do something for you."; const editor = new Editor(createTestTui(), defaultEditorTheme, { + prompt: "> ", + promptStyle: (text) => `\x1b[34m${text}\x1b[39m`, placeholder, placeholderStyle: (text) => `\x1b[90m${text}\x1b[39m`, }); + editor.focused = true; - const contentLine = editor.render(80)[1]!; + try { + const contentLine = editor.render(80)[1]!; - assert.ok(contentLine.startsWith("\x1b[90m\x1b[7mA\x1b[27msk Diffex")); - assert.strictEqual(stripVTControlCharacters(contentLine).trimEnd(), placeholder); + assert.ok(contentLine.startsWith(`\x1b[34m> \x1b[39m${CURSOR_MARKER}\x1b[7mA\x1b[0m\x1b[90msk Diffex`)); + assert.strictEqual( + stripVTControlCharacters(contentLine.replace(CURSOR_MARKER, "")).trimEnd(), + `> ${placeholder}`, + ); + } finally { + editor.focused = false; + editor.render(80); + } + }); + + it("blinks while the empty editor is focused", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + let renderRequests = 0; + const editor = new Editor( + createTestTui(() => { + renderRequests++; + }), + defaultEditorTheme, + { + prompt: "> ", + promptStyle: (text) => `\x1b[34m${text}\x1b[39m`, + placeholder: "Ask Diffex to do something for you.", + placeholderStyle: (text) => `\x1b[90m${text}\x1b[39m`, + }, + ); + editor.focused = true; + + try { + assert.ok(editor.render(80)[1]!.startsWith(`\x1b[34m> \x1b[39m${CURSOR_MARKER}\x1b[7mA\x1b[0m`)); + + mock.timers.tick(500); + assert.strictEqual(renderRequests, 1); + assert.ok(editor.render(80)[1]!.startsWith(`\x1b[34m> \x1b[39m${CURSOR_MARKER}\x1b[90mAsk Diffex`)); + + mock.timers.tick(500); + assert.strictEqual(renderRequests, 2); + assert.ok(editor.render(80)[1]!.startsWith(`\x1b[34m> \x1b[39m${CURSOR_MARKER}\x1b[7mA\x1b[0m`)); + } finally { + editor.focused = false; + editor.render(80); + mock.timers.reset(); + } + }); + + it("keeps typed and wrapped text to the right of the prompt", () => { + const editor = new Editor(createTestTui(), defaultEditorTheme, { prompt: "> " }); + editor.setText("123456789"); + + const contentLines = editor + .render(10) + .slice(1, -1) + .map((line) => stripVTControlCharacters(line)); + + assert.ok(contentLines[0]!.startsWith("> ")); + assert.ok(contentLines.slice(1).every((line) => line.startsWith(" "))); + assert.ok(contentLines.every((line) => !line.startsWith("123"))); }); }); From f346f5108a2e79a80d45e06da0b926d1c6aba983 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 14:02:09 -0700 Subject: [PATCH 06/13] fix(coding-agent): support DeepSeek plan schema - Give update_plan the root object type required by DeepSeek - Preserve strict update-or-clear branches in the tool contract --- .../src/core/tools/update-plan.ts | 41 ++++++++++--------- .../test/update-plan-tool.test.ts | 38 ++++++++++++++++- 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/packages/coding-agent/src/core/tools/update-plan.ts b/packages/coding-agent/src/core/tools/update-plan.ts index 396f1b469..67ce51679 100644 --- a/packages/coding-agent/src/core/tools/update-plan.ts +++ b/packages/coding-agent/src/core/tools/update-plan.ts @@ -41,25 +41,28 @@ const updatePlanItemSchema = Type.Object( { additionalProperties: false }, ); -const updatePlanSchema = Type.Union([ - Type.Object( - { - plan: Type.Array(updatePlanItemSchema, { - minItems: 1, - description: "Full ordered plan snapshot.", - }), - explanation: Type.Optional(Type.String({ description: "Reason for a material plan change." })), - }, - { additionalProperties: false }, - ), - Type.Object( - { - clear: Type.Literal(true, { description: "Explicitly abandon and clear the current plan." }), - explanation: Type.Optional(Type.String({ description: "Reason for clearing the plan." })), - }, - { additionalProperties: false }, - ), -]); +const updatePlanSchema = Type.Unsafe({ + type: "object", + anyOf: [ + Type.Object( + { + plan: Type.Array(updatePlanItemSchema, { + minItems: 1, + description: "Full ordered plan snapshot.", + }), + explanation: Type.Optional(Type.String({ description: "Reason for a material plan change." })), + }, + { additionalProperties: false }, + ), + Type.Object( + { + clear: Type.Literal(true, { description: "Explicitly abandon and clear the current plan." }), + explanation: Type.Optional(Type.String({ description: "Reason for clearing the plan." })), + }, + { additionalProperties: false }, + ), + ], +}); export const updatePlanToolSystemPromptContribution = { snippet: "track progress with an ordered task checklist", diff --git a/packages/coding-agent/test/update-plan-tool.test.ts b/packages/coding-agent/test/update-plan-tool.test.ts index 6607d4e0d..cc65c52d1 100644 --- a/packages/coding-agent/test/update-plan-tool.test.ts +++ b/packages/coding-agent/test/update-plan-tool.test.ts @@ -1,4 +1,5 @@ import type { AgentToolResult } from "@earendil-works/diffex-agent-core"; +import { getModel, streamSimple } from "@earendil-works/diffex-ai/compat"; import type { TUI } from "@earendil-works/diffex-tui"; import { beforeAll, describe, expect, test } from "vitest"; import type { ToolDefinition } from "../src/core/extensions/types.ts"; @@ -164,9 +165,10 @@ describe("update_plan tool", () => { ).rejects.toThrow("Plan item 1 status must be pending, in_progress, or completed."); }); - test("publishes an exact strict update-or-clear schema", () => { + test("publishes a DeepSeek-compatible object schema", () => { const definition = createUpdatePlanToolDefinition(); const parameters = definition.parameters as { + type?: string; anyOf?: Array<{ additionalProperties?: boolean; properties?: { @@ -188,6 +190,7 @@ describe("update_plan tool", () => { const update = parameters.anyOf?.[0]; const clear = parameters.anyOf?.[1]; + expect(parameters.type).toBe("object"); expect(parameters.anyOf).toHaveLength(2); expect(update?.required).toEqual(["plan"]); expect(update?.additionalProperties).toBe(false); @@ -205,6 +208,39 @@ describe("update_plan tool", () => { expect(clear?.properties?.explanation?.type).toBe("string"); }); + test("sends the object schema through a DeepSeek interaction", async () => { + const model = getModel("deepseek", "deepseek-v4-flash"); + expect(model).toBeDefined(); + if (!model) throw new Error("Expected the DeepSeek model fixture"); + let payload: unknown; + + const result = await streamSimple( + model, + { + messages: [{ role: "user", content: "Plan this task", timestamp: Date.now() }], + tools: [createUpdatePlanTool()], + }, + { + apiKey: "test-key", + fetch: async () => + new Response( + 'data: {"id":"chatcmpl-test","choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}\n\ndata: [DONE]\n\n', + { status: 200, headers: { "content-type": "text/event-stream" } }, + ), + onPayload: (requestPayload) => { + payload = requestPayload; + }, + }, + ).result(); + + const sentPayload = payload as { + tools?: Array<{ function?: { name?: string; parameters?: { type?: string } } }>; + }; + const updatePlan = sentPayload.tools?.find((tool) => tool.function?.name === "update_plan"); + expect(updatePlan?.function?.parameters?.type).toBe("object"); + expect(result.stopReason).toBe("stop"); + }); + test("marks both tool representations as sequential", () => { expect(createUpdatePlanToolDefinition().executionMode).toBe("sequential"); expect(createUpdatePlanTool().executionMode).toBe("sequential"); From 57f7a253ca3b56b7f3303090ef4612e823adf5bf Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 14:04:45 -0700 Subject: [PATCH 07/13] fix(coding-agent): refine bash prompt - Makes bash mode visually distinct without duplicating its activation prefix - Keeps the concealed prefix editable so deleting it restores the normal prompt --- .../src/modes/interactive/interactive-mode.ts | 51 ++++++++++--------- .../src/modes/interactive/theme/dark.json | 3 +- .../src/modes/interactive/theme/light.json | 3 +- .../modes/interactive/theme/theme-schema.json | 6 ++- .../src/modes/interactive/theme/theme.ts | 8 ++- .../coding-agent/test/theme-detection.test.ts | 37 ++++++++++++++ packages/tui/src/components/editor.ts | 27 +++++++--- .../test/editor-placeholder-cursor.test.ts | 23 +++++++++ 8 files changed, 121 insertions(+), 37 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0cfa11bbd..e8d8af8d4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -598,7 +598,9 @@ export class InteractiveMode { paddingX: editorPaddingX, autocompleteMaxVisible, prompt: SELECTED_LIST_PREFIX, - promptStyle: (text) => theme.fg("selectionAccent", text), + promptStyle: (text) => + this.isBashMode ? theme.fg("bashMode", text.replace(/^>/, "!")) : theme.fg("selectionAccent", text), + concealedInputPrefix: "!", placeholder: "Ask Diffex to do something for you.", placeholderStyle: (text) => theme.fg("muted", text), }); @@ -2831,10 +2833,8 @@ export class InteractiveMode { this.defaultEditor.onChange = (text: string) => { const wasBashMode = this.isBashMode; - this.isBashMode = text.trimStart().startsWith("!"); - if (wasBashMode !== this.isBashMode) { - this.updateEditorBorderColor(); - } + this.isBashMode = text.startsWith("!"); + if (wasBashMode !== this.isBashMode) this.updateEditorBorderColor(); }; // Handle clipboard paste (triggered on Ctrl+V). Images are attached by path; @@ -2888,6 +2888,22 @@ export class InteractiveMode { text = text.trim(); if (!text) return; + if (text.startsWith("!")) { + const isExcluded = text.startsWith("!!"); + const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim(); + if (!command) return; + if (this.session.isBashRunning) { + this.showWarning("A bash command is already running. Press Esc to cancel it first."); + this.editor.setText(text); + return; + } + this.editor.addToHistory?.(text); + await this.handleBashCommand(command, isExcluded); + this.isBashMode = false; + this.updateEditorBorderColor(); + return; + } + // Handle commands if (text === "/settings") { this.showSettingsSelector(); @@ -3023,24 +3039,6 @@ export class InteractiveMode { return; } - // Handle bash command (! for normal, !! for excluded from context) - if (text.startsWith("!")) { - const isExcluded = text.startsWith("!!"); - const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim(); - if (command) { - if (this.session.isBashRunning) { - this.showWarning("A bash command is already running. Press Esc to cancel it first."); - this.editor.setText(text); - return; - } - this.editor.addToHistory?.(text); - await this.handleBashCommand(command, isExcluded); - this.isBashMode = false; - this.updateEditorBorderColor(); - return; - } - } - // Queue input during compaction (extension commands execute immediately) if (this.session.isCompacting) { if (this.isExtensionCommand(text)) { @@ -4042,8 +4040,7 @@ export class InteractiveMode { if (this.isBashMode) { this.editor.borderColor = theme.getBashModeBorderColor(); } else { - const level = this.session.thinkingLevel || "off"; - this.editor.borderColor = theme.getThinkingBorderColor(level); + this.editor.borderColor = getEditorTheme().borderColor; } this.ui.requestRender(); } @@ -4141,6 +4138,10 @@ export class InteractiveMode { // ========================================================================= clearEditor(): void { + if (this.isBashMode) { + this.isBashMode = false; + this.updateEditorBorderColor(); + } this.editor.setText(""); this.ui.requestRender(); } diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index cfb0d5665..5df81e8ad 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -26,6 +26,7 @@ "border": "blue", "borderAccent": "cyan", "borderMuted": "darkGray", + "editorBorder": "startupIntroAccent", "success": "green", "error": "red", "warning": "yellow", @@ -83,7 +84,7 @@ "thinkingXhigh": "#d183e8", "thinkingMax": "#ff5fff", - "bashMode": "green" + "bashMode": "red" }, "export": { "pageBg": "#18181e", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 78d055230..e2dea33a9 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -25,6 +25,7 @@ "border": "blue", "borderAccent": "teal", "borderMuted": "lightGray", + "editorBorder": "#3C597A", "success": "green", "error": "red", "warning": "yellow", @@ -82,7 +83,7 @@ "thinkingXhigh": "#8b008b", "thinkingMax": "#af005f", - "bashMode": "green" + "bashMode": "red" }, "export": { "pageBg": "#f8f8f8", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index 465825bf1..2dbc3ff5d 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -34,7 +34,7 @@ }, "colors": { "type": "object", - "description": "Theme color definitions (thinkingMax, scrollbarThumb, startupIntroText, startupIntroAccent, selectionAccent, and autocompleteSelected are optional and use compatible fallbacks)", + "description": "Theme color definitions (editorBorder, thinkingMax, scrollbarThumb, startupIntroText, startupIntroAccent, selectionAccent, and autocompleteSelected are optional and use compatible fallbacks)", "required": [ "accent", "border", @@ -105,6 +105,10 @@ "$ref": "#/$defs/colorValue", "description": "Subtle borders" }, + "editorBorder": { + "$ref": "#/$defs/colorValue", + "description": "Editor border color (optional, falls back to borderMuted)" + }, "success": { "$ref": "#/$defs/colorValue", "description": "Success states" diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index b5e1ed8d3..7c6d561e2 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -39,6 +39,7 @@ const ThemeJsonSchema = Type.Object({ border: ColorValueSchema, borderAccent: ColorValueSchema, borderMuted: ColorValueSchema, + editorBorder: Type.Optional(ColorValueSchema), success: ColorValueSchema, error: ColorValueSchema, warning: ColorValueSchema, @@ -117,6 +118,7 @@ export type ThemeColor = | "border" | "borderAccent" | "borderMuted" + | "editorBorder" | "success" | "error" | "warning" @@ -337,6 +339,7 @@ function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors startupIntroAccent: ColorValue; selectionAccent: ColorValue; autocompleteSelected: ColorValue; + editorBorder: ColorValue; } { const startupIntroAccent = colors.startupIntroAccent ?? colors.mdLink; const selectionAccent = colors.selectionAccent ?? colors.autocompleteSelected ?? startupIntroAccent; @@ -348,6 +351,7 @@ function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors startupIntroAccent, selectionAccent, autocompleteSelected: colors.autocompleteSelected ?? selectionAccent, + editorBorder: colors.editorBorder ?? colors.borderMuted, }; } @@ -438,7 +442,7 @@ export class Theme { } getThinkingBorderColor(level: ThinkingLevel): (str: string) => string { - // Map thinking levels to dedicated theme colors + // Retained for extension compatibility. The built-in editor no longer uses thinking colors. switch (level) { case "off": return (str: string) => this.fg("thinkingOff", str); @@ -1320,7 +1324,7 @@ export function getSelectListTheme(): SelectListTheme { export function getEditorTheme(): EditorTheme { return { - borderColor: (text: string) => theme.fg("borderMuted", text), + borderColor: (text: string) => theme.fg("editorBorder", text), selectList: getSelectListTheme(), }; } diff --git a/packages/coding-agent/test/theme-detection.test.ts b/packages/coding-agent/test/theme-detection.test.ts index 2c9230874..503e1aaaa 100644 --- a/packages/coding-agent/test/theme-detection.test.ts +++ b/packages/coding-agent/test/theme-detection.test.ts @@ -243,3 +243,40 @@ describe("selection theme", () => { expect(legacyTheme.getFgAnsi("selectionAccent")).toBe(legacyTheme.getFgAnsi("autocompleteSelected")); }); }); + +describe("editor border theme", () => { + it("keeps the editor border independent from thinking colors", () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + initTheme("dark", false); + + const expected = "\x1b[38;2;137;180;250m"; + expect(theme.getFgAnsi("editorBorder")).toBe(expected); + expect(getEditorTheme().borderColor("line")).toBe(`${expected}line\x1b[39m`); + expect(theme.getThinkingBorderColor("low")("line")).not.toBe(`${expected}line\x1b[39m`); + expect(theme.getBashModeBorderColor()("line")).toBe("\x1b[38;2;204;102;102mline\x1b[39m"); + }); + + it("uses the fixed light theme color", () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + initTheme("light", false); + + expect(theme.getFgAnsi("editorBorder")).toBe("\x1b[38;2;60;89;122m"); + expect(theme.getBashModeBorderColor()("line")).toBe("\x1b[38;2;170;85;85mline\x1b[39m"); + }); + + it("falls back to borderMuted for existing custom themes", () => { + const themeJson = JSON.parse( + readFileSync(new URL("../src/modes/interactive/theme/dark.json", import.meta.url), "utf8"), + ) as { name: string; colors: Record }; + themeJson.name = "legacy-editor-border-theme"; + delete themeJson.colors.editorBorder; + const testDir = mkdtempSync(join(tmpdir(), "diffex-editor-border-theme-")); + tempDirs.push(testDir); + const themePath = join(testDir, "legacy-editor-border-theme.json"); + writeFileSync(themePath, JSON.stringify(themeJson)); + + const legacyTheme = loadThemeFromPath(themePath, "truecolor"); + + expect(legacyTheme.getFgAnsi("editorBorder")).toBe(legacyTheme.getFgAnsi("borderMuted")); + }); +}); diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 7a9053c6a..1caf30889 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -235,6 +235,7 @@ export interface EditorOptions { autocompleteMaxVisible?: number; prompt?: string; promptStyle?: (text: string) => string; + concealedInputPrefix?: string; placeholder?: string; placeholderStyle?: (text: string) => string; } @@ -287,6 +288,7 @@ export class Editor implements Component, Focusable { private paddingX: number = 0; private prompt: string = ""; private promptStyle: (text: string) => string = (text) => text; + private concealedInputPrefix: string = ""; private placeholder: string = ""; private placeholderStyle: (text: string) => string = (text) => text; private cursorBlinkVisible = true; @@ -363,6 +365,7 @@ export class Editor implements Component, Focusable { this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5; this.prompt = options.prompt ?? ""; this.promptStyle = options.promptStyle ?? ((text) => text); + this.concealedInputPrefix = options.concealedInputPrefix ?? ""; this.placeholder = options.placeholder ?? ""; this.placeholderStyle = options.placeholderStyle ?? ((text) => text); } @@ -984,8 +987,14 @@ export class Editor implements Component, Focusable { // Process each logical line for (let i = 0; i < this.state.lines.length; i++) { - const line = this.state.lines[i] || ""; + const logicalLine = this.state.lines[i] || ""; + const concealedPrefixLength = + i === 0 && this.concealedInputPrefix && logicalLine.startsWith(this.concealedInputPrefix) + ? this.concealedInputPrefix.length + : 0; + const line = logicalLine.slice(concealedPrefixLength); const isCurrentLine = i === this.state.cursorLine; + const cursorPos = Math.max(0, this.state.cursorCol - concealedPrefixLength); const lineVisibleWidth = visibleWidth(line); if (lineVisibleWidth <= contentWidth) { @@ -994,7 +1003,7 @@ export class Editor implements Component, Focusable { layoutLines.push({ text: line, hasCursor: true, - cursorPos: this.state.cursorCol, + cursorPos, }); } else { layoutLines.push({ @@ -1010,7 +1019,6 @@ export class Editor implements Component, Focusable { const chunk = chunks[chunkIndex]; if (!chunk) continue; - const cursorPos = this.state.cursorCol; const isLastChunk = chunkIndex === chunks.length - 1; // Determine if cursor is in this chunk @@ -1812,20 +1820,25 @@ export class Editor implements Component, Focusable { const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = []; for (let i = 0; i < this.state.lines.length; i++) { - const line = this.state.lines[i] || ""; + const logicalLine = this.state.lines[i] || ""; + const concealedPrefixLength = + i === 0 && this.concealedInputPrefix && logicalLine.startsWith(this.concealedInputPrefix) + ? this.concealedInputPrefix.length + : 0; + const line = logicalLine.slice(concealedPrefixLength); const lineVisWidth = visibleWidth(line); if (line.length === 0) { // Empty line still takes one visual line - visualLines.push({ logicalLine: i, startCol: 0, length: 0 }); + visualLines.push({ logicalLine: i, startCol: concealedPrefixLength, length: 0 }); } else if (lineVisWidth <= width) { - visualLines.push({ logicalLine: i, startCol: 0, length: line.length }); + visualLines.push({ logicalLine: i, startCol: concealedPrefixLength, length: line.length }); } else { // Line needs wrapping - use word-aware wrapping const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]); for (const chunk of chunks) { visualLines.push({ logicalLine: i, - startCol: chunk.startIndex, + startCol: concealedPrefixLength + chunk.startIndex, length: chunk.endIndex - chunk.startIndex, }); } diff --git a/packages/tui/test/editor-placeholder-cursor.test.ts b/packages/tui/test/editor-placeholder-cursor.test.ts index 2076da72b..3e38cc921 100644 --- a/packages/tui/test/editor-placeholder-cursor.test.ts +++ b/packages/tui/test/editor-placeholder-cursor.test.ts @@ -84,4 +84,27 @@ describe("Editor placeholder cursor", () => { assert.ok(contentLines.slice(1).every((line) => line.startsWith(" "))); assert.ok(contentLines.every((line) => !line.startsWith("123"))); }); + + it("conceals a stored input prefix without affecting deletion", () => { + const changes: string[] = []; + const editor = new Editor(createTestTui(), defaultEditorTheme, { + prompt: "! ", + concealedInputPrefix: "!", + }); + editor.onChange = (text) => changes.push(text); + editor.setText("!pwd"); + + assert.strictEqual(editor.getText(), "!pwd"); + assert.strictEqual(stripVTControlCharacters(editor.render(80)[1]!).trimEnd(), "! pwd"); + + editor.handleInput("\x7f"); + editor.handleInput("\x7f"); + editor.handleInput("\x7f"); + assert.strictEqual(editor.getText(), "!"); + assert.strictEqual(stripVTControlCharacters(editor.render(80)[1]!).trimEnd(), "!"); + + editor.handleInput("\x7f"); + assert.strictEqual(editor.getText(), ""); + assert.strictEqual(changes.at(-1), ""); + }); }); From 74ecd227ba7b7ac2307d35e42a10a40d5eb839e2 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 14:51:40 -0700 Subject: [PATCH 08/13] fix(coding-agent): refine dark theme tool colors --- packages/coding-agent/src/core/tools/read.ts | 2 +- packages/coding-agent/src/core/tools/update-plan.ts | 4 ++-- .../coding-agent/src/modes/interactive/theme/dark.json | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index be32c4732..05b769571 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -73,7 +73,7 @@ function formatReadLineRange(args: ReadRenderArgs | undefined, theme: Theme): st if (args?.offset === undefined && args?.limit === undefined) return ""; const startLine = args.offset ?? 1; const endLine = args.limit !== undefined ? startLine + args.limit - 1 : ""; - return theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); + return theme.fg("accent", `:${startLine}${endLine ? `-${endLine}` : ""}`); } function formatReadCall(args: ReadRenderArgs | undefined, theme: Theme, cwd: string): string { diff --git a/packages/coding-agent/src/core/tools/update-plan.ts b/packages/coding-agent/src/core/tools/update-plan.ts index 67ce51679..63be8b42d 100644 --- a/packages/coding-agent/src/core/tools/update-plan.ts +++ b/packages/coding-agent/src/core/tools/update-plan.ts @@ -186,9 +186,9 @@ function formatPlanSummary(plan: readonly UpdatePlanItem[]): string { function formatPlanMarker(status: unknown, theme: Theme): string { switch (status) { case "completed": - return theme.fg("success", "[x]"); + return theme.fg("toolOutput", "[x]"); case "in_progress": - return theme.fg("warning", "[>]"); + return theme.fg("toolOutput", "[>]"); case "pending": return theme.fg("toolOutput", "[ ]"); default: diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index 5df81e8ad..49110df6e 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -4,20 +4,20 @@ "vars": { "cyan": "#00d7ff", "blue": "#5f87ff", - "green": "#b5bd68", + "green": "#58A170", "red": "#cc6666", "yellow": "#F0F04A", "text": "#d4d4d4", "gray": "#808080", "dimGray": "#666666", "darkGray": "#505050", - "accent": "#8abeb7", + "accent": "#A5E0D5", "startupIntroText": "#CDD6F4", "startupIntroAccent": "#89B4FA", "selectedBg": "#3a3a4a", "userMsgBg": "#343541", "toolPendingBg": "#282832", - "toolSuccessBg": "#283228", + "toolSuccessBg": "#2E2D2D", "toolErrorBg": "#3c2828", "customMsgBg": "#2d2838" }, @@ -27,7 +27,7 @@ "borderAccent": "cyan", "borderMuted": "darkGray", "editorBorder": "startupIntroAccent", - "success": "green", + "success": "#6BA65D", "error": "red", "warning": "yellow", "muted": "gray", From fa5853f4fa92e613b46fbb554af88adeeb0fab89 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 15:16:50 -0700 Subject: [PATCH 09/13] fix(coding-agent): avoid transient invalid plan arguments --- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/tools/update-plan.ts | 12 +++++++---- .../test/update-plan-tool.test.ts | 21 +++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e4cd9f10e..80d9d8731 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Fixed `update_plan` briefly rendering partial streaming arguments as invalid. - Fixed extension TUI method wrappers recursing indefinitely when delegating to the original method ([#7731](https://github.com/earendil-works/pi/issues/7731)). - Fixed right-click not pasting clipboard text in fullscreen mode on Windows. diff --git a/packages/coding-agent/src/core/tools/update-plan.ts b/packages/coding-agent/src/core/tools/update-plan.ts index 63be8b42d..ba7161253 100644 --- a/packages/coding-agent/src/core/tools/update-plan.ts +++ b/packages/coding-agent/src/core/tools/update-plan.ts @@ -203,13 +203,13 @@ function formatPlanStep(step: unknown, theme: Theme): string { return theme.fg("toolOutput", step); } -function formatPlanCall(args: unknown, theme: Theme): string { +function formatPlanCall(args: unknown, theme: Theme, showInvalidArgs: boolean): string { let text = theme.fg("toolTitle", theme.bold("update_plan")); let details: UpdatePlanToolDetails; try { details = parseUpdatePlanOperation(args); } catch { - return `${text}\n${invalidArgText(theme)}`; + return showInvalidArgs ? `${text}\n${invalidArgText(theme)}` : text; } if (details.explanation !== undefined) { @@ -247,8 +247,12 @@ export function createUpdatePlanToolDefinition(): ToolDefinition { expect(createUpdatePlanTool().executionMode).toBe("sequential"); }); + test("does not render partial update_plan arguments as invalid", () => { + const definition = createUpdatePlanToolDefinition() as ToolDefinition; + const component = new ToolExecutionComponent( + "update_plan", + "tool-streaming", + {}, + {}, + definition, + createFakeTui(), + process.cwd(), + ); + + const streaming = stripAnsi(component.render(120).join("\n")); + expect(streaming).toContain("update_plan"); + expect(streaming).not.toContain("[invalid arg]"); + + component.setArgsComplete(); + const complete = stripAnsi(component.render(120).join("\n")); + expect(complete).toContain("[invalid arg]"); + }); + test("renders update explanations and checklists once while suppressing the successful result body", () => { const input = { explanation: "Repository evidence changed.", From 1946f6e2ee8b27aa88ea3beaa8c18c71e3f15b54 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 15:17:23 -0700 Subject: [PATCH 10/13] fix(tui): remove clear confirmation - Keeps the transcript empty after starting a fresh session - Avoids redundant feedback when the cleared state is already visible --- .../coding-agent/src/modes/interactive/interactive-mode.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index e8d8af8d4..ba0ca2981 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -6273,9 +6273,6 @@ export class InteractiveMode { if (result.cancelled) { return; } - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ Session cleared")}`, 1, 1)); - this.ui.requestRender(); } catch (error: unknown) { await this.handleFatalRuntimeError("Failed to create session", error); } From 978932370eef806821ecd110e64724c135cd6f03 Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 15:18:14 -0700 Subject: [PATCH 11/13] docs(coding-agent): remove update plan changelog entry --- packages/coding-agent/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 80d9d8731..e4cd9f10e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,7 +13,6 @@ ### Fixed -- Fixed `update_plan` briefly rendering partial streaming arguments as invalid. - Fixed extension TUI method wrappers recursing indefinitely when delegating to the original method ([#7731](https://github.com/earendil-works/pi/issues/7731)). - Fixed right-click not pasting clipboard text in fullscreen mode on Windows. From a4492426f65809559264aa52438a2e54cc2b8e0c Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 15:26:38 -0700 Subject: [PATCH 12/13] fix(tui): update success background - Aligns completed tool cards with the revised dark palette - Keeps the styling centralized in the existing theme token --- packages/coding-agent/src/modes/interactive/theme/dark.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index 49110df6e..bf18a75a1 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -17,7 +17,7 @@ "selectedBg": "#3a3a4a", "userMsgBg": "#343541", "toolPendingBg": "#282832", - "toolSuccessBg": "#2E2D2D", + "toolSuccessBg": "#212129", "toolErrorBg": "#3c2828", "customMsgBg": "#2d2838" }, From bd0a6218fa362e6b5c8074c65b2ebaeaa287a2aa Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Sat, 15 Aug 2026 15:38:15 -0700 Subject: [PATCH 13/13] fix(coding-agent): update syntax color expectation --- packages/coding-agent/test/syntax-highlight.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/test/syntax-highlight.test.ts b/packages/coding-agent/test/syntax-highlight.test.ts index 472098484..56e17b175 100644 --- a/packages/coding-agent/test/syntax-highlight.test.ts +++ b/packages/coding-agent/test/syntax-highlight.test.ts @@ -63,7 +63,7 @@ describe("theme syntax highlighting", () => { const lines = highlightCode("-old\n+new\n", "diff"); expect(lines[0]).toBe("\x1b[38;2;204;102;102m-old\x1b[39m"); - expect(lines[1]).toBe("\x1b[38;2;181;189;104m+new\x1b[39m"); + expect(lines[1]).toBe("\x1b[38;2;88;161;112m+new\x1b[39m"); }); it("keeps cli-highlight default styled scopes mapped to theme styles", () => {