From b9b9c8e7cd3a875f667fec9698bc7df219018aff Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Mon, 13 Jul 2026 23:57:39 +0200 Subject: [PATCH 1/4] feat(tui): auto-detect dark/light theme from the terminal When config.ui.theme is unset or set to "auto", query the terminal for its color-scheme preference before the first render. The detection uses two concurrent queries with a 150 ms timeout each: the DSR color-scheme report (CSI ? 996 n, a direct dark/light answer) and the OSC 11 background-color query (parsed to RGB and classified via Rec. 709 relative luminance). The fastest signal wins; an unresponsive terminal falls back to the default dark preset without blocking startup. Add "auto" to the ui.theme config type so users can opt in explicitly, and document the query ladder in term-capabilities.ts. The manual /theme command continues to override the detected preset for the session. --- src/config/types.mayros.ts | 4 +- src/tui/term-capabilities.test.ts | 67 ++++++++++++++++++++++++++++++- src/tui/term-capabilities.ts | 46 +++++++++++++++++++++ src/tui/tui.ts | 13 +++++- 4 files changed, 125 insertions(+), 5 deletions(-) diff --git a/src/config/types.mayros.ts b/src/config/types.mayros.ts index 8c8bdc4e..ec33a448 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/term-capabilities.test.ts b/src/tui/term-capabilities.test.ts index d75d3dcb..8cff30ca 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 3d2f2c3b..6234acf4 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.ts b/src/tui/tui.ts index 872eb8e5..06d6200d 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"; @@ -280,7 +281,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; @@ -518,6 +522,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); From 373943acbe6e22feb227caa3029c0aad86ab49a4 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Tue, 14 Jul 2026 00:00:03 +0200 Subject: [PATCH 2/4] test(tui): cover paste-collapse and grapheme caret behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add no-regression coverage for the two input features inherited from the terminal UI dependency: grapheme-aware caret movement (Intl.Segmenter) and paste-collapse markers. Both are implemented in the Editor class the TUI extends, so the tests pin the environment contract rather than the Editor internals. The grapheme tests verify that emoji with skin-tone modifiers, flag emoji, and CJK characters each count as a single grapheme — the invariant the caret relies on. The paste-marker tests verify the marker shape ([paste #N +M lines], [paste #N M chars], [paste #N]) so that cursor movement and submission expansion keep working if the format changes upstream. Also update the welcome-screen quick-start hint to reflect that Ctrl+C now aborts the active run (Esc remains a secondary path). --- src/tui/components/welcome-screen.ts | 2 +- src/tui/paste-grapheme.test.ts | 82 ++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 src/tui/paste-grapheme.test.ts diff --git a/src/tui/components/welcome-screen.ts b/src/tui/components/welcome-screen.ts index fe692b50..88cc23ce 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 00000000..767b8e2e --- /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); + }); +}); From 51881e17f45a6f6b82945227e77c9bf19f9e22e7 Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Tue, 14 Jul 2026 00:02:34 +0200 Subject: [PATCH 3/4] perf(tui): split streaming markdown at the stable block boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-parsing the full markdown on every streaming delta is O(n²) over a response. Split the assistant message text at the last stable block boundary (a blank line outside any open code fence) into a stable prefix and an active suffix. Only the suffix is re-parsed per delta; the prefix reuses the Markdown component's internal render cache, which turns the per-delta cost into O(n). Add stream-boundary.ts with a pure, unit-tested findStableBoundary function that walks backwards through blank lines and skips any that sit inside an open code fence. Refactor AssistantMessageComponent to hold two Markdown instances (prefix + suffix) and only update the prefix when its text actually grows. --- src/tui/components/assistant-message.ts | 39 ++++++++++++-- src/tui/stream-boundary.test.ts | 69 +++++++++++++++++++++++++ src/tui/stream-boundary.ts | 64 +++++++++++++++++++++++ 3 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 src/tui/stream-boundary.test.ts create mode 100644 src/tui/stream-boundary.ts diff --git a/src/tui/components/assistant-message.ts b/src/tui/components/assistant-message.ts index 2d9c3c84..dfe79712 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/stream-boundary.test.ts b/src/tui/stream-boundary.test.ts new file mode 100644 index 00000000..f948eeec --- /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 00000000..e8fe78c6 --- /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; +} From 881cd68e241f006f5683b797c2dc37410349b8dc Mon Sep 17 00:00:00 2001 From: ApiliumDevTeam Date: Tue, 14 Jul 2026 00:09:10 +0200 Subject: [PATCH 4/4] feat(tui): per-section detail visibility for tools and thinking Replace the binary expanded/collapsed toggle with a three-state model: hidden, collapsed, and expanded. Each section cycles independently via its keyboard shortcut. - Tools (Ctrl+O): hidden renders only the header line, collapsed shows a 12-line preview, expanded shows the full output. - Thinking (Ctrl+T): hidden suppresses thinking text, collapsed and expanded both show it (a truncated thinking mode is deferred to a follow-up that restructures the composer). The section state is the new source of truth in TuiStateAccess; the existing toolsExpanded and showThinking fields are kept as compatibility shims. Add a nextSectionState helper and unit tests for the cycle. --- src/tui/components/chat-log.ts | 8 ++++++++ src/tui/components/tool-execution.ts | 26 ++++++++++++++++++++---- src/tui/section-visibility.test.ts | 28 ++++++++++++++++++++++++++ src/tui/tui-event-handlers.test.ts | 2 ++ src/tui/tui-session-actions.test.ts | 2 ++ src/tui/tui-types.ts | 21 +++++++++++++++++++ src/tui/tui.ts | 30 ++++++++++++++++++++++++---- 7 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 src/tui/section-visibility.test.ts diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index 41d142e1..18187496 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 80f71059..df48c41e 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/section-visibility.test.ts b/src/tui/section-visibility.test.ts new file mode 100644 index 00000000..d0f273ff --- /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/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 11409889..fe1c60a0 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 b28cf92a..47f96d3c 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 a9c27d6c..e3bb69c3 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 06d6200d..ca5e3eb6 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -42,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"; @@ -306,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; @@ -425,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; }, @@ -948,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 = () => { @@ -963,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 = () => {