diff --git a/src/config/types.mayros.ts b/src/config/types.mayros.ts index 8c8bdc4..ec33a44 100644 --- a/src/config/types.mayros.ts +++ b/src/config/types.mayros.ts @@ -68,8 +68,8 @@ export type MayrosConfig = { ui?: { /** Accent color for Mayros UI chrome (hex). */ seamColor?: string; - /** TUI color theme preset. */ - theme?: "dark" | "light" | "high-contrast"; + /** TUI color theme preset, or "auto" to detect from the terminal. */ + theme?: "auto" | "dark" | "light" | "high-contrast"; /** Enable vim editing mode in the TUI. */ vim?: boolean; /** Custom keybindings for TUI actions. */ diff --git a/src/tui/components/assistant-message.ts b/src/tui/components/assistant-message.ts index 2d9c3c8..dfe7971 100644 --- a/src/tui/components/assistant-message.ts +++ b/src/tui/components/assistant-message.ts @@ -1,21 +1,50 @@ import { Container, Markdown, Spacer } from "@earendil-works/pi-tui"; +import { findStableBoundary } from "../stream-boundary.js"; import { markdownTheme, theme } from "../theme/theme.js"; +/** + * Renders an assistant message with incremental markdown parsing. + * + * The text is split at the last stable block boundary (see findStableBoundary) + * into a stable prefix and an active suffix. Only the suffix is re-parsed on + * each streaming delta; the prefix reuses the Markdown component's internal + * render cache, turning the per-delta cost from O(n²) into O(n). + */ export class AssistantMessageComponent extends Container { - private body: Markdown; + private stablePrefix: Markdown; + private activeSuffix: Markdown; + private committedPrefixText = ""; constructor(text: string) { super(); - this.body = new Markdown(text, 1, 0, markdownTheme, { + this.addChild(new Spacer(1)); + this.stablePrefix = this.createMarkdown(""); + this.activeSuffix = this.createMarkdown(""); + this.addChild(this.stablePrefix); + this.addChild(this.activeSuffix); + if (text) this.setText(text); + } + + private createMarkdown(text: string): Markdown { + return new Markdown(text, 1, 0, markdownTheme, { // Keep assistant body text in terminal default foreground so contrast // follows the user's terminal theme (dark or light). color: (line) => theme.assistantText(line), }); - this.addChild(new Spacer(1)); - this.addChild(this.body); } setText(text: string) { - this.body.setText(text); + const boundary = findStableBoundary(text); + const prefixText = boundary > 0 ? text.slice(0, boundary) : ""; + const suffixText = boundary > 0 ? text.slice(boundary) : text; + + // Only update the prefix when it actually grew — this is what lets the + // Markdown cache hit on every delta once a block is committed. + if (prefixText !== this.committedPrefixText) { + this.committedPrefixText = prefixText; + this.stablePrefix.setText(prefixText); + } + // The suffix always changes on each delta. + this.activeSuffix.setText(suffixText); } } diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index 41d142e..1818749 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -1,5 +1,6 @@ import type { Component } from "@earendil-works/pi-tui"; import { Container, Spacer, Text } from "@earendil-works/pi-tui"; +import type { SectionState } from "../tui-types.js"; import { theme } from "../theme/theme.js"; import { AssistantMessageComponent } from "./assistant-message.js"; import { ToolExecutionComponent } from "./tool-execution.js"; @@ -207,4 +208,11 @@ export class ChatLog extends Container { tool.setExpanded(expanded); } } + + setToolSectionState(state: SectionState) { + this.toolsExpanded = state === "expanded"; + for (const tool of this.toolById.values()) { + tool.setSectionState(state); + } + } } diff --git a/src/tui/components/tool-execution.ts b/src/tui/components/tool-execution.ts index 80f7105..df48c41 100644 --- a/src/tui/components/tool-execution.ts +++ b/src/tui/components/tool-execution.ts @@ -64,7 +64,7 @@ export class ToolExecutionComponent extends Container { private toolName: string; private args: unknown; private result?: ToolResult; - private expanded = false; + private sectionState: import("../tui-types.js").SectionState = "collapsed"; private isError = false; private isPartial = true; @@ -92,10 +92,20 @@ export class ToolExecutionComponent extends Container { } setExpanded(expanded: boolean) { - this.expanded = expanded; + this.sectionState = expanded ? "expanded" : "collapsed"; this.refresh(); } + setSectionState(state: import("../tui-types.js").SectionState) { + this.sectionState = state; + this.refresh(); + } + + /** When hidden, the component renders only the header line. */ + get isHidden(): boolean { + return this.sectionState === "hidden"; + } + setResult(result: ToolResult | undefined, opts?: { isError?: boolean }) { this.result = result; this.isPartial = false; @@ -132,6 +142,14 @@ export class ToolExecutionComponent extends Container { const raw = extractText(this.result); const isDiff = DIFF_TOOLS.has(this.toolName) && raw && !this.isPartial; + // Hidden: only the header line, no output body. + if (this.sectionState === "hidden") { + this.output.setText(""); + return; + } + + const isExpanded = this.sectionState === "expanded"; + if (isDiff) { const colored = renderDiff(raw, { add: theme.success, @@ -145,7 +163,7 @@ export class ToolExecutionComponent extends Container { add: theme.success, del: theme.error, }); - const maxLines = this.expanded ? Infinity : DIFF_PREVIEW_LINES; + const maxLines = isExpanded ? Infinity : DIFF_PREVIEW_LINES; const display = colored.length > maxLines ? [...colored.slice(0, maxLines), "…", statsLine] @@ -157,7 +175,7 @@ export class ToolExecutionComponent extends Container { : this.isPartial ? "…" : ""; - if (!this.expanded && text) { + if (!isExpanded && text) { const lines = text.split("\n"); const preview = lines.length > PREVIEW_LINES ? `${lines.slice(0, PREVIEW_LINES).join("\n")}\n…` : text; diff --git a/src/tui/components/welcome-screen.ts b/src/tui/components/welcome-screen.ts index fe692b5..88cc23c 100644 --- a/src/tui/components/welcome-screen.ts +++ b/src/tui/components/welcome-screen.ts @@ -137,7 +137,7 @@ export class WelcomeScreen implements Component { ["/help", "all commands"], ["/agents", "switch agents"], ["Ctrl+V", "paste images"], - ["Esc", "abort run"], + ["Ctrl+C", "abort run / exit"], ]; const sessionHeader = bold(accent("Session")); diff --git a/src/tui/paste-grapheme.test.ts b/src/tui/paste-grapheme.test.ts new file mode 100644 index 0000000..767b8e2 --- /dev/null +++ b/src/tui/paste-grapheme.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +/** + * No-regression coverage for the paste-collapse and grapheme-aware caret + * behavior inherited from the terminal UI dependency. + * + * Both features are implemented inside the Editor class the TUI extends, so + * these tests pin the environment contract (Intl.Segmenter availability and + * the paste-marker shape) rather than the Editor internals. If either + * contract breaks, the TUI's caret or paste handling degrades silently. + */ + +describe("grapheme-aware caret contract", () => { + it("Intl.Segmenter is available with grapheme granularity", () => { + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); + expect(segmenter).toBeDefined(); + const segments = [...segmenter.segment("ab")]; + expect(segments.length).toBe(2); + }); + + it("treats emoji with skin-tone modifiers as a single grapheme", () => { + // 👩🏻 is woman (U+1F469) + light skin tone (U+1F3FB) — two code points, + // one user-perceived character. The caret must jump over it atomically. + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); + const segments = [...segmenter.segment("👩🏻")]; + expect(segments.length).toBe(1); + }); + + it("treats a flag emoji (regional indicator pair) as a single grapheme", () => { + // 🇲🇽 is U+1F1F2 + U+1F1FD — two regional indicator symbols, one flag. + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); + const segments = [...segmenter.segment("🇲🇽")]; + expect(segments.length).toBe(1); + }); + + it("treats CJK characters as one grapheme each", () => { + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); + const segments = [...segmenter.segment("你好")]; + expect(segments.length).toBe(2); + }); +}); + +/** + * The paste-marker shape used by the Editor to collapse large pastes into a + * single atomic token. The regex must match the formats the Editor emits so + * that cursor movement and submission expansion keep working. If the format + * ever changes upstream, this test surfaces the break. + */ +const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/; + +describe("paste-collapse marker contract", () => { + it("matches a multi-line paste marker", () => { + expect(PASTE_MARKER_REGEX.test("[paste #1 +42 lines]")).toBe(true); + }); + + it("matches a character-count paste marker", () => { + expect(PASTE_MARKER_REGEX.test("[paste #2 1500 chars]")).toBe(true); + }); + + it("matches a bare paste marker (no count)", () => { + expect(PASTE_MARKER_REGEX.test("[paste #3]")).toBe(true); + }); + + it("does not match arbitrary bracketed text", () => { + expect(PASTE_MARKER_REGEX.test("[not a paste]")).toBe(false); + expect(PASTE_MARKER_REGEX.test("[paste foo]")).toBe(false); + }); + + it("a paste marker is a single atomic grapheme for cursor movement", () => { + // When the Editor segments text containing a paste marker, the marker + // must be treated as one unit so arrow keys jump over it in one press. + // We approximate this by checking the marker has no internal whitespace + // graphemes that a naive split would create. + const marker = "[paste #1 +42 lines]"; + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); + const segments = [...segmenter.segment(marker)]; + // The marker contains spaces, so it has multiple graphemes — the Editor + // applies its own marker-aware segmentation on top. This test documents + // that the marker is a compact, single-line token (no embedded newlines). + expect(segments.some((s) => s.segment.includes("\n"))).toBe(false); + }); +}); diff --git a/src/tui/section-visibility.test.ts b/src/tui/section-visibility.test.ts new file mode 100644 index 0000000..d0f273f --- /dev/null +++ b/src/tui/section-visibility.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { SECTION_CYCLE, nextSectionState } from "./tui-types.js"; + +describe("nextSectionState", () => { + it("cycles hidden → collapsed", () => { + expect(nextSectionState("hidden")).toBe("collapsed"); + }); + + it("cycles collapsed → expanded", () => { + expect(nextSectionState("collapsed")).toBe("expanded"); + }); + + it("cycles expanded → hidden (wraps around)", () => { + expect(nextSectionState("expanded")).toBe("hidden"); + }); + + it("the cycle covers all three states in order", () => { + expect(SECTION_CYCLE).toEqual(["hidden", "collapsed", "expanded"]); + }); + + it("produces a full cycle back to the start after 3 steps", () => { + let state: "hidden" | "collapsed" | "expanded" = "hidden"; + state = nextSectionState(state); + state = nextSectionState(state); + state = nextSectionState(state); + expect(state).toBe("hidden"); + }); +}); diff --git a/src/tui/stream-boundary.test.ts b/src/tui/stream-boundary.test.ts new file mode 100644 index 0000000..f948eee --- /dev/null +++ b/src/tui/stream-boundary.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { findStableBoundary } from "./stream-boundary.js"; + +describe("findStableBoundary", () => { + it("returns 0 for empty text", () => { + expect(findStableBoundary("")).toBe(0); + }); + + it("returns 0 for a single paragraph with no blank line", () => { + expect(findStableBoundary("hello world")).toBe(0); + }); + + it("splits at the last blank line between paragraphs", () => { + const text = "first paragraph\n\nsecond paragraph"; + const boundary = findStableBoundary(text); + expect(text.slice(0, boundary)).toBe("first paragraph\n\n"); + expect(text.slice(boundary)).toBe("second paragraph"); + }); + + it("splits at the last of multiple blank lines", () => { + const text = "para one\n\npara two\n\npara three"; + const boundary = findStableBoundary(text); + expect(text.slice(boundary)).toBe("para three"); + }); + + it("keeps an open code fence in the suffix (does not split mid-fence)", () => { + const text = "intro\n\n```ts\nconst x = 1;\n// still in fence"; + const boundary = findStableBoundary(text); + // The blank line before the fence opens is a stable boundary (fence count + // is 0 there), so "intro" becomes the stable prefix and the open fence + // stays in the suffix — which is correct, the prefix is a complete block. + expect(text.slice(0, boundary)).toBe("intro\n\n"); + expect(text.slice(boundary)).toBe("```ts\nconst x = 1;\n// still in fence"); + }); + + it("splits after a closed code fence", () => { + const text = "intro\n\n```ts\nconst x = 1;\n```\n\nnext paragraph"; + const boundary = findStableBoundary(text); + expect(text.slice(boundary)).toBe("next paragraph"); + expect(text.slice(0, boundary)).toContain("```"); + expect(text.slice(0, boundary)).toContain("```ts"); + }); + + it("handles multiple closed fences", () => { + const text = "a\n\n```ts\ncode1\n```\n\nb\n\n```py\ncode2\n```\n\nc"; + const boundary = findStableBoundary(text); + expect(text.slice(boundary)).toBe("c"); + }); + + it("treats tilde fences the same as backtick fences", () => { + const text = "intro\n\n~~~ts\nconst x = 1;\n~~~\n\nafter"; + const boundary = findStableBoundary(text); + expect(text.slice(boundary)).toBe("after"); + }); + + it("returns 0 when the only blank line is inside an open fence", () => { + const text = "```ts\n\nconst x = 1;\n// open"; + const boundary = findStableBoundary(text); + expect(boundary).toBe(0); + }); + + it("handles trailing newlines", () => { + const text = "paragraph\n\n"; + const boundary = findStableBoundary(text); + // The boundary is after the blank line; suffix is empty. + expect(text.slice(0, boundary)).toBe("paragraph\n\n"); + expect(text.slice(boundary)).toBe(""); + }); +}); diff --git a/src/tui/stream-boundary.ts b/src/tui/stream-boundary.ts new file mode 100644 index 0000000..e8fe78c --- /dev/null +++ b/src/tui/stream-boundary.ts @@ -0,0 +1,64 @@ +/** + * Stable-boundary detection for incremental markdown streaming. + * + * When streaming a model response, re-parsing the full markdown on every delta + * is O(n²). Splitting the text at the last stable block boundary lets the + * prefix be cached (it never changes once committed) and only the suffix is + * re-parsed per delta, turning the cost into O(n). + * + * A "stable boundary" is a blank line (`\n\n`) that sits outside any open code + * fence, because that is where a markdown block (paragraph, heading, code + * block, list) is guaranteed to be complete. Splitting inside an open fence or + * mid-paragraph would orphan partial tokens and change the rendering. + */ + +const FENCE_LINE = /^\s*(`{3,}|~{3,})/; + +/** + * Count the number of code-fence lines in the text up to (but not including) + * the given end index. An odd count means a fence is currently open. + */ +function fenceCountUpTo(text: string, endIndex: number): number { + let count = 0; + let pos = 0; + while (pos < endIndex) { + const eol = text.indexOf("\n", pos); + const lineEnd = eol === -1 || eol >= endIndex ? endIndex : eol; + const line = text.slice(pos, lineEnd); + if (FENCE_LINE.test(line)) count++; + if (eol === -1 || eol >= endIndex) break; + pos = eol + 1; + } + return count; +} + +/** + * Find the index of the last stable block boundary in the text — the position + * just after a `\n\n` that is outside any open code fence. Returns 0 when no + * stable boundary exists (the entire text is a single in-flight block). + * + * The return value is the start index of the suffix (the unstable part); + * `text.slice(0, result)` is the stable prefix, `text.slice(result)` is the + * suffix. + */ +export function findStableBoundary(text: string): number { + if (!text) return 0; + + // Walk backwards through `\n\n` occurrences, returning the first one that + // sits in an even-fence-count region (i.e. outside any open fence). + let searchFrom = text.length; + while (searchFrom > 0) { + const boundary = text.lastIndexOf("\n\n", searchFrom - 1); + if (boundary === -1) return 0; + + // The boundary is stable only if fences are balanced before it. + const fencesBefore = fenceCountUpTo(text, boundary); + if (fencesBefore % 2 === 0) { + return boundary + 2; // start of the block after the blank line + } + + // Inside an open fence — keep walking back. + searchFrom = boundary; + } + return 0; +} diff --git a/src/tui/term-capabilities.test.ts b/src/tui/term-capabilities.test.ts index d75d3dc..8cff30c 100644 --- a/src/tui/term-capabilities.test.ts +++ b/src/tui/term-capabilities.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from "vitest"; -import { detectKeyboardProtocol, enableKeyboardProtocol } from "./term-capabilities.js"; +import { describe, expect, it, vi } from "vitest"; +import { + detectKeyboardProtocol, + detectTerminalColorScheme, + enableKeyboardProtocol, + relativeLuminance, + type TerminalColorQuerier, +} from "./term-capabilities.js"; describe("detectKeyboardProtocol", () => { it("returns kitty for kitty/WezTerm/ghostty terminals", () => { @@ -84,3 +90,60 @@ describe("enableKeyboardProtocol", () => { } }); }); + +describe("relativeLuminance", () => { + it("returns ~0 for black", () => { + expect(relativeLuminance({ r: 0, g: 0, b: 0 })).toBeCloseTo(0, 1); + }); + + it("returns ~1 for white", () => { + expect(relativeLuminance({ r: 255, g: 255, b: 255 })).toBeCloseTo(1, 1); + }); + + it("weights green more than red and blue (Rec. 709)", () => { + const lum = relativeLuminance({ r: 0, g: 255, b: 0 }); + expect(lum).toBeGreaterThan(0.7); + }); + + it("classifies a dark navy background as dark (< 0.5)", () => { + expect(relativeLuminance({ r: 30, g: 30, b: 40 })).toBeLessThan(0.5); + }); +}); + +describe("detectTerminalColorScheme", () => { + function makeQuerier( + scheme: "dark" | "light" | undefined, + bg: { r: number; g: number; b: number } | undefined, + ): TerminalColorQuerier { + return { + queryTerminalColorScheme: vi.fn().mockResolvedValue(scheme), + queryTerminalBackgroundColor: vi.fn().mockResolvedValue(bg), + }; + } + + it("returns the DSR scheme when available (dark)", async () => { + const tui = makeQuerier("dark", undefined); + expect(await detectTerminalColorScheme(tui)).toBe("dark"); + }); + + it("returns the DSR scheme when available (light)", async () => { + const tui = makeQuerier("light", undefined); + expect(await detectTerminalColorScheme(tui)).toBe("light"); + }); + + it("falls back to OSC 11 luminance when DSR is unsupported", async () => { + // DSR returns undefined; bright background → light + const tui = makeQuerier(undefined, { r: 250, g: 250, b: 250 }); + expect(await detectTerminalColorScheme(tui)).toBe("light"); + }); + + it("classifies a dark background via OSC 11", async () => { + const tui = makeQuerier(undefined, { r: 20, g: 20, b: 25 }); + expect(await detectTerminalColorScheme(tui)).toBe("dark"); + }); + + it("falls back to dark when neither query responds", async () => { + const tui = makeQuerier(undefined, undefined); + expect(await detectTerminalColorScheme(tui)).toBe("dark"); + }); +}); diff --git a/src/tui/term-capabilities.ts b/src/tui/term-capabilities.ts index 3d2f2c3..6234acf 100644 --- a/src/tui/term-capabilities.ts +++ b/src/tui/term-capabilities.ts @@ -17,6 +17,14 @@ import process from "node:process"; +/** Minimal surface of the TUI instance needed for terminal color queries. */ +export type TerminalColorQuerier = { + queryTerminalColorScheme(opts: { timeoutMs: number }): Promise<"dark" | "light" | undefined>; + queryTerminalBackgroundColor(opts: { + timeoutMs: number; + }): Promise<{ r: number; g: number; b: number } | undefined>; +}; + /** Terminal identifiers that reliably support an extended keyboard protocol. */ const EXTENDED_KEY_TERMINALS = new Set([ "iTerm.app", @@ -120,3 +128,41 @@ export function enableKeyboardProtocol( }, }; } + +// --------------------------------------------------------------------------- +// Terminal color-scheme detection (dark/light auto-theme) +// --------------------------------------------------------------------------- + +/** + * Compute the relative luminance of an RGB color using the Rec. 709 weighting. + * Values >= 0.5 are considered "light" backgrounds. + */ +export function relativeLuminance(rgb: { r: number; g: number; b: number }): number { + return (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255; +} + +/** + * Detect whether the terminal has a dark or light background, using the best + * available signal. Returns "dark" when no signal is available (the prior + * default). + * + * Query ladder, fastest signal wins: + * 1. DSR color-scheme (`CSI ? 996 n`) — terminals reply with a direct + * dark/light answer. Cleanest signal when supported. + * 2. OSC 11 background color — parse the RGB reply and compute luminance. + * 3. Fallback to "dark". + * + * Both queries are run concurrently and each has a 150 ms timeout, so an + * unresponsive terminal adds at most ~150 ms to startup before falling back. + */ +export async function detectTerminalColorScheme( + tui: TerminalColorQuerier, +): Promise<"dark" | "light"> { + const [scheme, bg] = await Promise.all([ + tui.queryTerminalColorScheme({ timeoutMs: 150 }), + tui.queryTerminalBackgroundColor({ timeoutMs: 150 }), + ]); + if (scheme === "dark" || scheme === "light") return scheme; + if (bg) return relativeLuminance(bg) >= 0.5 ? "light" : "dark"; + return "dark"; +} diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 1140988..fe1c60a 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -40,6 +40,8 @@ describe("tui-event-handlers: handleAgentEvent", () => { autoMessageSent: false, toolsExpanded: false, showThinking: false, + toolSectionState: "collapsed", + thinkingSectionState: "collapsed", connectionStatus: "connected", activityStatus: "idle", statusTimeout: null, diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index b28cf92..47f96d3 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -39,6 +39,8 @@ describe("tui session actions", () => { autoMessageSent: false, toolsExpanded: false, showThinking: false, + toolSectionState: "collapsed", + thinkingSectionState: "collapsed", pendingImages: new Map(), connectionStatus: "connected", activityStatus: "idle", diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index a9c27d6..e3bb69c 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -1,3 +1,21 @@ +/** + * Per-section detail visibility. Replaces the previous binary expanded/not + * with a three-state model: + * - "hidden": the section renders only a header line (or nothing). + * - "collapsed": a bounded preview (tool output: first N lines). + * - "expanded": full content. + */ +export type SectionState = "hidden" | "collapsed" | "expanded"; + +export type SectionKey = "thinking" | "tools"; + +export const SECTION_CYCLE: SectionState[] = ["hidden", "collapsed", "expanded"]; + +export function nextSectionState(current: SectionState): SectionState { + const idx = SECTION_CYCLE.indexOf(current); + return SECTION_CYCLE[(idx + 1) % SECTION_CYCLE.length]; +} + export type TuiOptions = { url?: string; token?: string; @@ -109,6 +127,9 @@ export type TuiStateAccess = { autoMessageSent: boolean; toolsExpanded: boolean; showThinking: boolean; + /** Per-section detail visibility (the source of truth; toolsExpanded/showThinking are shims). */ + toolSectionState: SectionState; + thinkingSectionState: SectionState; connectionStatus: string; activityStatus: string; statusTimeout: ReturnType | null; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 872eb8e..ca5e3eb 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -27,6 +27,7 @@ import { CustomEditor } from "./components/custom-editor.js"; import { WelcomeScreen } from "./components/welcome-screen.js"; import { GatewayChatClient } from "./gateway-chat.js"; import { enableKeyboardProtocol } from "./term-capabilities.js"; +import { detectTerminalColorScheme } from "./term-capabilities.js"; import type { ThemePreset } from "./theme/palettes.js"; import { THEME_PRESETS } from "./theme/palettes.js"; import { editorTheme, theme, setThemePreset } from "./theme/theme.js"; @@ -41,11 +42,13 @@ import { createSessionActions } from "./tui-session-actions.js"; import type { AgentSummary, PendingImage, + SectionState, SessionInfo, SessionScope, TuiOptions, TuiStateAccess, } from "./tui-types.js"; +import { nextSectionState } from "./tui-types.js"; import { buildWaitingStatusMessage, defaultWaitingPhrases } from "./tui-waiting.js"; export { resolveFinalAssistantText } from "./tui-formatters.js"; @@ -280,7 +283,10 @@ export async function runTui(opts: TuiOptions) { } const configTheme = config.ui?.theme; - if (configTheme && THEME_PRESETS.includes(configTheme as ThemePreset)) { + // "auto" (or unset) defers to terminal color-scheme detection after the TUI + // instance is created; until then the default "dark" preset is in place. + const themeIsAuto = !configTheme || configTheme === "auto"; + if (!themeIsAuto && THEME_PRESETS.includes(configTheme as ThemePreset)) { setThemePreset(configTheme as ThemePreset); } const keybindingsConfig = config.ui?.keybindings; @@ -302,6 +308,8 @@ export async function runTui(opts: TuiOptions) { let wasDisconnected = false; let toolsExpanded = false; let showThinking = false; + let toolSectionState: SectionState = "collapsed"; + let thinkingSectionState: SectionState = "collapsed"; let pairingHintShown = false; let gatewayDownHintShown = false; let outputStyle: string | undefined; @@ -421,6 +429,18 @@ export async function runTui(opts: TuiOptions) { set showThinking(value) { showThinking = value; }, + get toolSectionState() { + return toolSectionState; + }, + set toolSectionState(value) { + toolSectionState = value; + }, + get thinkingSectionState() { + return thinkingSectionState; + }, + set thinkingSectionState(value) { + thinkingSectionState = value; + }, get connectionStatus() { return connectionStatus; }, @@ -518,6 +538,13 @@ export async function runTui(opts: TuiOptions) { }); const tui = new TUI(new ProcessTerminal()); + // Auto-detect the terminal's dark/light preference before the first render. + // Queries run concurrently with a short timeout, so an unresponsive terminal + // falls back to the default "dark" preset without blocking startup. + if (themeIsAuto) { + const detected = await detectTerminalColorScheme(tui); + setThemePreset(detected); + } const dedupeBackspace = createBackspaceDeduper(); tui.addInputListener((data) => { const next = dedupeBackspace(data); @@ -937,9 +964,10 @@ export async function runTui(opts: TuiOptions) { process.exit(0); }; editor.onCtrlO = () => { - toolsExpanded = !toolsExpanded; - chatLog.setToolsExpanded(toolsExpanded); - setActivityStatus(toolsExpanded ? "tools expanded" : "tools collapsed"); + toolSectionState = nextSectionState(toolSectionState); + toolsExpanded = toolSectionState === "expanded"; + chatLog.setToolSectionState(toolSectionState); + setActivityStatus(`tools: ${toolSectionState}`); tui.requestRender(); }; editor.onCtrlL = () => { @@ -952,7 +980,12 @@ export async function runTui(opts: TuiOptions) { void openSessionSelector(); }; editor.onCtrlT = () => { - showThinking = !showThinking; + thinkingSectionState = nextSectionState(thinkingSectionState); + // Thinking text is baked into the assistant message at compose time, so + // toggling requires reloading history. "hidden" suppresses thinking; + // "collapsed" and "expanded" both show it (a truncated mode would require + // restructuring the composer — deferred to a follow-up). + showThinking = thinkingSectionState !== "hidden"; void loadHistory(); }; editor.onShiftTab = () => {