From 8566d803ba08544598e8c830718abef65ac6be6d Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Thu, 2 Jul 2026 13:53:50 -0700 Subject: [PATCH 1/4] feat(watch): dedicated OpenTUI live view for tsq watch Interactive `tsq watch` now launches a focused OpenTUI view (single scrollable task list mirroring the classic watch layout: header, summary, list, footer) instead of the flicker-prone full-screen ANSI-clear loop that broke scrolling on lists taller than the terminal. - Add WatchApp (watch-app.tsx) + testable helpers (watch-helpers.ts): reuses fetchTasks, sortTasks, computeSummary, buildTreeLines, THEME, visibleRange for windowed scrolling. Supports flat/tree, pause, j/k + g/G navigation, r refresh. - index.tsx branches on TSQ_TUI_MODE so one bundled binary serves both `tsq tui` (tabbed App) and `tsq watch` (WatchApp). - opentui.rs: add should_launch_opentui_watch / launch_opentui_watch, share launch/resolution logic; `tsq tui` clears inherited TSQ_TUI_MODE so it always renders the tabbed app. - meta.rs: execute_watch launches the OpenTUI view, falls back to the built-in Rust loop when unavailable / --json / --once / non-TTY. The `watch --once --json` contract is unchanged (it is the data source for both views), so the fallback loop and existing tests are untouched. --- src/cli/commands/meta.rs | 13 +- src/cli/opentui.rs | 75 +++++-- tui-opentui/src/index.tsx | 4 +- tui-opentui/src/watch-app.tsx | 259 ++++++++++++++++++++++++ tui-opentui/src/watch-helpers.ts | 44 ++++ tui-opentui/tests/watch-helpers.test.ts | 69 +++++++ 6 files changed, 442 insertions(+), 22 deletions(-) create mode 100644 tui-opentui/src/watch-app.tsx create mode 100644 tui-opentui/src/watch-helpers.ts create mode 100644 tui-opentui/tests/watch-helpers.test.ts diff --git a/src/cli/commands/meta.rs b/src/cli/commands/meta.rs index 581e303..8074b8e 100644 --- a/src/cli/commands/meta.rs +++ b/src/cli/commands/meta.rs @@ -4,7 +4,9 @@ use crate::cli::action::{GlobalOpts, run_action}; use crate::cli::init_flow::{ InitCommandOptions, InitPlan, InitResolutionContext, resolve_init_plan, run_init_wizard, }; -use crate::cli::opentui::{launch_opentui, should_launch_opentui}; +use crate::cli::opentui::{ + launch_opentui, launch_opentui_watch, should_launch_opentui, should_launch_opentui_watch, +}; use crate::cli::parsers::{as_optional_string, parse_positive_int, parse_status_csv}; use crate::cli::render::{print_history, print_orphans_result, print_repair_result}; use crate::cli::tui::{TuiOptions, TuiView, start_tui}; @@ -326,6 +328,15 @@ pub fn execute_watch(service: &TasqueService, args: WatchArgs, opts: GlobalOpts) return error.exit_code; } }; + if should_launch_opentui_watch(&watch_options) { + match launch_opentui_watch(&watch_options) { + Ok(exit_code) => return exit_code, + Err(error) => { + eprintln!("WARN: {}. Falling back to built-in watch renderer.", error); + } + } + } + start_watch(service, watch_options) } diff --git a/src/cli/opentui.rs b/src/cli/opentui.rs index cdd8783..cd10e38 100644 --- a/src/cli/opentui.rs +++ b/src/cli/opentui.rs @@ -1,5 +1,7 @@ use crate::app::runtime::find_tasque_root; use crate::cli::tui::{TuiOptions, TuiView}; +use crate::cli::watch::WatchOptions; +use crate::types::TaskStatus; use std::io::IsTerminal; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -11,6 +13,14 @@ const BUNDLED_TUI_BINARY: &str = "tsq-tui.exe"; const BUNDLED_TUI_BINARY: &str = "tsq-tui"; pub fn should_launch_opentui(options: &TuiOptions) -> bool { + interactive_launch_ready(options.json, options.once) && launch_target_available() +} + +pub fn should_launch_opentui_watch(options: &WatchOptions) -> bool { + interactive_launch_ready(options.json, options.once) && launch_target_available() +} + +fn interactive_launch_ready(json: bool, once: bool) -> bool { if std::env::var("TSQ_OPENTUI_DISABLE") .ok() .as_deref() @@ -18,14 +28,13 @@ pub fn should_launch_opentui(options: &TuiOptions) -> bool { { return false; } - if options.json - || options.once - || !std::io::stdin().is_terminal() - || !std::io::stdout().is_terminal() - { - return false; - } + !json + && !once + && std::io::stdin().is_terminal() + && std::io::stdout().is_terminal() +} +fn launch_target_available() -> bool { if explicit_bundled_tui_path().is_some() && resolve_bundled_tui_path().is_some() { return true; } @@ -45,8 +54,39 @@ pub fn should_launch_opentui(options: &TuiOptions) -> bool { } pub fn launch_opentui(options: &TuiOptions) -> Result { + let mut command = build_launch_command()?; + apply_shared_env(&mut command); + // Clear any inherited watch mode so `tsq tui` always renders the tabbed App. + command.env_remove("TSQ_TUI_MODE"); + command.env("TSQ_TUI_INTERVAL", options.interval.to_string()); + command.env("TSQ_TUI_STATUS", status_csv(&options.statuses)); + command.env("TSQ_TUI_VIEW", view_to_env(options.view)); + + if let Some(assignee) = options.assignee.as_deref() { + command.env("TSQ_TUI_ASSIGNEE", assignee); + } + + run_command(command) +} + +pub fn launch_opentui_watch(options: &WatchOptions) -> Result { + let mut command = build_launch_command()?; + apply_shared_env(&mut command); + command.env("TSQ_TUI_MODE", "watch"); + command.env("TSQ_TUI_INTERVAL", options.interval.to_string()); + command.env("TSQ_TUI_STATUS", status_csv(&options.statuses)); + command.env("TSQ_WATCH_TREE", if options.tree { "1" } else { "0" }); + + if let Some(assignee) = options.assignee.as_deref() { + command.env("TSQ_TUI_ASSIGNEE", assignee); + } + + run_command(command) +} + +fn build_launch_command() -> Result { let prefer_bundle = explicit_bundled_tui_path().is_some() || explicit_entry_path().is_none(); - let mut command = if let Some(bundle) = if prefer_bundle { + let command = if let Some(bundle) = if prefer_bundle { resolve_bundled_tui_path() } else { None @@ -61,23 +101,19 @@ pub fn launch_opentui(options: &TuiOptions) -> Result { command.arg("run").arg(&entry); command }; + Ok(command) +} +fn apply_shared_env(command: &mut Command) { if let Ok(bin) = std::env::current_exe() { command.env("TSQ_TUI_BIN", bin); } - - command.env("TSQ_TUI_INTERVAL", options.interval.to_string()); - command.env("TSQ_TUI_STATUS", status_csv(options)); - command.env("TSQ_TUI_VIEW", view_to_env(options.view)); - - if let Some(assignee) = options.assignee.as_deref() { - command.env("TSQ_TUI_ASSIGNEE", assignee); - } - if let Some(root) = find_tasque_root() { command.current_dir(root); } +} +fn run_command(mut command: Command) -> Result { let status = command .status() .map_err(|error| format!("failed launching OpenTUI: {error}"))?; @@ -179,9 +215,8 @@ fn view_to_env(view: TuiView) -> &'static str { } } -fn status_csv(options: &TuiOptions) -> String { - options - .statuses +fn status_csv(statuses: &[TaskStatus]) -> String { + statuses .iter() .map(|status| crate::domain::event_payload_codecs::task_status_as_str(*status)) .collect::>() diff --git a/tui-opentui/src/index.tsx b/tui-opentui/src/index.tsx index 12e3a73..126233e 100644 --- a/tui-opentui/src/index.tsx +++ b/tui-opentui/src/index.tsx @@ -1,6 +1,8 @@ import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; import { App } from "./tui-app"; +import { WatchApp } from "./watch-app"; +const mode = process.env.TSQ_TUI_MODE?.trim(); const renderer = await createCliRenderer(); -createRoot(renderer).render(); +createRoot(renderer).render(mode === "watch" ? : ); diff --git a/tui-opentui/src/watch-app.tsx b/tui-opentui/src/watch-app.tsx new file mode 100644 index 0000000..135b52a --- /dev/null +++ b/tui-opentui/src/watch-app.tsx @@ -0,0 +1,259 @@ +import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + type DataSnapshot, + createInFlightGuard, + fetchTasks, + readConfigFromEnv, +} from "./data"; +import { + STATUS_COLORS, + computeSummary, + titleWithEllipsis, +} from "./model"; +import { THEME, clampIndex, pad, statusIcon, visibleRange } from "./tui-helpers"; +import { + buildWatchRows, + filterLabel, + metaBadge, + readWatchTreeFlag, +} from "./watch-helpers"; + +export function WatchApp() { + const config = useMemo(() => readConfigFromEnv(), []); + const tree = useMemo(() => readWatchTreeFlag(), []); + const renderer = useRenderer(); + const dimensions = useTerminalDimensions(); + + const [snapshot, setSnapshot] = useState({ + fetchedAt: "-", + tasks: [], + }); + const [warning, setWarning] = useState(); + const [paused, setPaused] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + + const pausedRef = useRef(paused); + pausedRef.current = paused; + const refreshRef = useRef<() => void>(() => {}); + + useEffect(() => { + let cancelled = false; + const guardedFetch = createInFlightGuard(() => fetchTasks(config)); + + const refresh = async () => { + const next = await guardedFetch(); + if (cancelled || !next) { + // Skipped poll (a previous fetch is still in flight) or torn down. + return; + } + setSnapshot(next); + setWarning(next.warning); + }; + + // Manual refresh (`r` / unpause) always fetches, ignoring the pause gate. + refreshRef.current = () => { + void refresh(); + }; + + void refresh(); + const timer = setInterval(() => { + if (pausedRef.current) { + return; + } + void refresh(); + }, config.intervalSeconds * 1000); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [config]); + + const rows = useMemo( + () => buildWatchRows(snapshot.tasks, tree), + [snapshot.tasks, tree], + ); + const summary = useMemo( + () => computeSummary(snapshot.tasks), + [snapshot.tasks], + ); + const filter = useMemo( + () => filterLabel(config.statusCsv, config.assignee), + [config.assignee, config.statusCsv], + ); + + // Keep the selection valid as the live list grows or shrinks between polls. + useEffect(() => { + setSelectedIndex((current) => clampIndex(current, rows.length)); + }, [rows.length]); + + const contentWidth = Math.max(40, dimensions.width - 6); + // Reserve rows for header (3) + summary (1) + footer (3) + borders/margins. + const rowBudget = Math.max(3, dimensions.height - 12); + const [start, end] = visibleRange(selectedIndex, rows.length, rowBudget); + const visibleRows = rows.slice(start, end); + + useKeyboard((key) => { + if (key.ctrl && key.name === "c") { + renderer.destroy(); + return; + } + if (key.name === "escape" || key.name === "q") { + renderer.destroy(); + return; + } + if (key.name === "r") { + refreshRef.current(); + return; + } + if (key.name === "p") { + setPaused((current) => { + const next = !current; + if (!next) { + // Resuming: refresh immediately so the view is current. + refreshRef.current(); + } + return next; + }); + return; + } + if (key.name === "g") { + // `G` (shift+g) jumps to the bottom, `g` to the top. + setSelectedIndex(key.shift ? Math.max(0, rows.length - 1) : 0); + return; + } + if (key.name === "home") { + setSelectedIndex(0); + return; + } + if (key.name === "end") { + setSelectedIndex(Math.max(0, rows.length - 1)); + return; + } + + const moveUp = key.name === "up" || key.name === "k"; + const moveDown = key.name === "down" || key.name === "j"; + if (!moveUp && !moveDown) { + return; + } + const delta = moveUp ? -1 : 1; + setSelectedIndex((current) => clampIndex(current + delta, rows.length)); + }); + + return ( + + + + + [tsq watch] + interval={config.intervalSeconds}s + {paused ? ⏸ paused : null} + + + refreshed + {snapshot.fetchedAt} + + + + + filter= + {filter} + + + + + active {summary.total} + in_progress {summary.inProgress} + open {summary.open} + blocked {summary.blocked} + + + {warning ? ( + + + warning: {warning} + + + ) : null} + + + + {rows.length === 0 ? ( + + no active tasks + + ) : ( + + {visibleRows.map((row, index) => { + const globalIndex = start + index; + const selected = globalIndex === selectedIndex; + const meta = metaBadge(row.task); + // Fixed columns keep every cell painted so the live buffer + // never leaves stale glyphs between refreshes. + const idField = pad(row.task.id, 9); + const titleBudget = Math.max( + 12, + contentWidth - 2 - 9 - 1 - row.prefix.length - (meta.length + 2), + ); + const title = titleWithEllipsis(row.task.title, titleBudget); + return ( + + + + {statusIcon(row.task.status)}{" "} + + {idField} + {row.prefix} + {title} + {` ${meta}`} + + + ); + })} + + )} + + + + + + {`q/esc quit r refresh p ${paused ? "resume" : "pause"} j/k or up/down scroll g/G top/bottom`} + + + + + ); +} diff --git a/tui-opentui/src/watch-helpers.ts b/tui-opentui/src/watch-helpers.ts new file mode 100644 index 0000000..6450b50 --- /dev/null +++ b/tui-opentui/src/watch-helpers.ts @@ -0,0 +1,44 @@ +import { type TasqueTask, sortTasks } from "./model"; +import { buildTreePrefix, buildTreeLines } from "./tui-helpers"; + +export interface WatchRow { + task: TasqueTask; + prefix: string; +} + +// Build the ordered rows for the watch list. Flat mode is a plain sorted list; +// tree mode reuses the shared tree builder so parent/child indentation matches +// `tsq watch --tree` and the Tasks tab of `tsq tui`. +export function buildWatchRows( + tasks: TasqueTask[], + tree: boolean, +): WatchRow[] { + const sorted = sortTasks(tasks); + if (!tree) { + return sorted.map((task) => ({ task, prefix: "" })); + } + return buildTreeLines(sorted).map((line) => ({ + task: line.task, + prefix: buildTreePrefix(line), + })); +} + +// Mirror the filter string rendered by the Rust watch header +// (`status:open,in_progress assignee:foo`). +export function filterLabel(statusCsv: string, assignee?: string): string { + const base = `status:${statusCsv}`; + return assignee ? `${base} assignee:${assignee}` : base; +} + +// Compact priority/assignee badge shown at the end of each row (`[p2] @foo`). +export function metaBadge(task: TasqueTask): string { + const parts = [`p${task.priority}`]; + if (task.assignee) { + parts.push(`@${task.assignee}`); + } + return parts.join(" "); +} + +export function readWatchTreeFlag(): boolean { + return process.env.TSQ_WATCH_TREE?.trim() === "1"; +} diff --git a/tui-opentui/tests/watch-helpers.test.ts b/tui-opentui/tests/watch-helpers.test.ts new file mode 100644 index 0000000..889523d --- /dev/null +++ b/tui-opentui/tests/watch-helpers.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import type { TasqueTask } from "../src/model"; +import { + buildWatchRows, + filterLabel, + metaBadge, +} from "../src/watch-helpers"; + +function task(overrides: Partial & { id: string }): TasqueTask { + return { + kind: "task", + title: `Title ${overrides.id}`, + status: "open", + priority: 2, + labels: [], + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +describe("buildWatchRows", () => { + test("flat mode returns sorted rows with empty prefixes", () => { + const rows = buildWatchRows( + [ + task({ id: "tsq-2", status: "open" }), + task({ id: "tsq-1", status: "in_progress" }), + ], + false, + ); + // in_progress sorts before open. + expect(rows.map((row) => row.task.id)).toEqual(["tsq-1", "tsq-2"]); + expect(rows.every((row) => row.prefix === "")).toBe(true); + }); + + test("tree mode indents children under their parent", () => { + const rows = buildWatchRows( + [ + task({ id: "tsq-1", kind: "epic" }), + task({ id: "tsq-2", parent_id: "tsq-1" }), + ], + true, + ); + const child = rows.find((row) => row.task.id === "tsq-2"); + expect(child?.prefix.length ?? 0).toBeGreaterThan(0); + const parent = rows.find((row) => row.task.id === "tsq-1"); + expect(parent?.prefix).toBe(""); + }); +}); + +describe("filterLabel", () => { + test("status only", () => { + expect(filterLabel("open,in_progress")).toBe("status:open,in_progress"); + }); + test("with assignee", () => { + expect(filterLabel("open", "alice")).toBe("status:open assignee:alice"); + }); +}); + +describe("metaBadge", () => { + test("priority only", () => { + expect(metaBadge(task({ id: "tsq-1", priority: 3 }))).toBe("p3"); + }); + test("priority and assignee", () => { + expect(metaBadge(task({ id: "tsq-1", priority: 1, assignee: "bob" }))).toBe( + "p1 @bob", + ); + }); +}); From a6e3cab2d5677e3725bfe1db6310a8dd59541882 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Thu, 2 Jul 2026 14:01:51 -0700 Subject: [PATCH 2/4] fix(watch): correct OpenTUI list row budget and expand tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review: - rowBudget under-counted vertical chrome (header/list/footer borders, margins, root padding, optional warning line), so the selected row could clip off the bottom near the end of a long list. Reserve all 16 non-list rows (17 with a warning) so visibleRange never overshoots. - Expand buildWatchRows tests: full status-priority ordering (flat), grandchild depth and unknown-parent-as-root (tree). Skipped: WatchApp interaction tests (timers/fetch/keyboard mocking) — no React component-test harness exists in this package; disproportionate for this change. --- tui-opentui/src/watch-app.tsx | 8 +++-- tui-opentui/tests/watch-helpers.test.ts | 45 ++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/tui-opentui/src/watch-app.tsx b/tui-opentui/src/watch-app.tsx index 135b52a..7625645 100644 --- a/tui-opentui/src/watch-app.tsx +++ b/tui-opentui/src/watch-app.tsx @@ -88,8 +88,12 @@ export function WatchApp() { }, [rows.length]); const contentWidth = Math.max(40, dimensions.width - 6); - // Reserve rows for header (3) + summary (1) + footer (3) + borders/margins. - const rowBudget = Math.max(3, dimensions.height - 12); + // Reserve every non-list row so the selected item never clips off the bottom: + // root padding 2 + header (border 2 + 3 lines, +1 when a warning shows) + // + list marginTop 1 + list (border 2 + padding 2) + footer marginTop 1 + // + footer (border 2 + 1 line) = 16, or 17 with the warning line. + const chromeRows = 16 + (warning ? 1 : 0); + const rowBudget = Math.max(3, dimensions.height - chromeRows); const [start, end] = visibleRange(selectedIndex, rows.length, rowBudget); const visibleRows = rows.slice(start, end); diff --git a/tui-opentui/tests/watch-helpers.test.ts b/tui-opentui/tests/watch-helpers.test.ts index 889523d..474248e 100644 --- a/tui-opentui/tests/watch-helpers.test.ts +++ b/tui-opentui/tests/watch-helpers.test.ts @@ -20,16 +20,27 @@ function task(overrides: Partial & { id: string }): TasqueTask { } describe("buildWatchRows", () => { - test("flat mode returns sorted rows with empty prefixes", () => { + test("flat mode sorts by the full status priority order", () => { + // Shuffled input across every status; expect the watch status order. const rows = buildWatchRows( [ - task({ id: "tsq-2", status: "open" }), - task({ id: "tsq-1", status: "in_progress" }), + task({ id: "c", status: "canceled" }), + task({ id: "o", status: "open" }), + task({ id: "x", status: "closed" }), + task({ id: "i", status: "in_progress" }), + task({ id: "d", status: "deferred" }), + task({ id: "b", status: "blocked" }), ], false, ); - // in_progress sorts before open. - expect(rows.map((row) => row.task.id)).toEqual(["tsq-1", "tsq-2"]); + expect(rows.map((row) => row.task.id)).toEqual([ + "i", + "o", + "b", + "d", + "x", + "c", + ]); expect(rows.every((row) => row.prefix === "")).toBe(true); }); @@ -46,6 +57,30 @@ describe("buildWatchRows", () => { const parent = rows.find((row) => row.task.id === "tsq-1"); expect(parent?.prefix).toBe(""); }); + + test("tree mode nests grandchildren deeper than children", () => { + const rows = buildWatchRows( + [ + task({ id: "root", kind: "epic" }), + task({ id: "child", parent_id: "root" }), + task({ id: "grandchild", parent_id: "child" }), + ], + true, + ); + const prefixLen = (id: string) => + rows.find((row) => row.task.id === id)?.prefix.length ?? 0; + expect(prefixLen("root")).toBe(0); + expect(prefixLen("grandchild")).toBeGreaterThan(prefixLen("child")); + }); + + test("tree mode treats a task with an unknown parent as a root", () => { + const rows = buildWatchRows( + [task({ id: "orphan", parent_id: "does-not-exist" })], + true, + ); + expect(rows).toHaveLength(1); + expect(rows[0]?.prefix).toBe(""); + }); }); describe("filterLabel", () => { From ac84301c4d179cda1b1e42ed23340e5130b0a0f6 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Thu, 2 Jul 2026 14:14:47 -0700 Subject: [PATCH 3/4] fix(watch): clear inherited assignee env; test gating; extract budget helper Address CodeRabbit PR #4 review: - Major: TSQ_TUI_ASSIGNEE could leak from the parent environment. Both launch_opentui and launch_opentui_watch now env_remove it when no assignee is requested (via shared apply_assignee_env), matching the existing TSQ_TUI_MODE handling, so a stale value can't silently filter a plain `tsq watch` / `tsq tui` run. - Add unit tests for the launch gating: json/once bypass, the TSQ_OPENTUI_DISABLE short-circuit, and apply_assignee_env set/clear. - Extract watchListRowBudget helper (watch-helpers.ts) for the list chrome math and unit-test it, so the off-by-N clipping class stays covered. Scoped to the watch view; the tabbed App layout is untouched. --- src/cli/opentui.rs | 65 ++++++++++++++++++++++--- tui-opentui/src/watch-app.tsx | 8 +-- tui-opentui/src/watch-helpers.ts | 12 +++++ tui-opentui/tests/watch-helpers.test.ts | 14 ++++++ 4 files changed, 85 insertions(+), 14 deletions(-) diff --git a/src/cli/opentui.rs b/src/cli/opentui.rs index cd10e38..e561604 100644 --- a/src/cli/opentui.rs +++ b/src/cli/opentui.rs @@ -61,10 +61,7 @@ pub fn launch_opentui(options: &TuiOptions) -> Result { command.env("TSQ_TUI_INTERVAL", options.interval.to_string()); command.env("TSQ_TUI_STATUS", status_csv(&options.statuses)); command.env("TSQ_TUI_VIEW", view_to_env(options.view)); - - if let Some(assignee) = options.assignee.as_deref() { - command.env("TSQ_TUI_ASSIGNEE", assignee); - } + apply_assignee_env(&mut command, options.assignee.as_deref()); run_command(command) } @@ -76,14 +73,25 @@ pub fn launch_opentui_watch(options: &WatchOptions) -> Result { command.env("TSQ_TUI_INTERVAL", options.interval.to_string()); command.env("TSQ_TUI_STATUS", status_csv(&options.statuses)); command.env("TSQ_WATCH_TREE", if options.tree { "1" } else { "0" }); - - if let Some(assignee) = options.assignee.as_deref() { - command.env("TSQ_TUI_ASSIGNEE", assignee); - } + apply_assignee_env(&mut command, options.assignee.as_deref()); run_command(command) } +// Set the assignee filter only when requested, and otherwise clear any value +// inherited from the parent environment so a stale `TSQ_TUI_ASSIGNEE` can't +// silently filter a plain `tsq watch` / `tsq tui` run. +fn apply_assignee_env(command: &mut Command, assignee: Option<&str>) { + match assignee { + Some(value) => { + command.env("TSQ_TUI_ASSIGNEE", value); + } + None => { + command.env_remove("TSQ_TUI_ASSIGNEE"); + } + } +} + fn build_launch_command() -> Result { let prefer_bundle = explicit_bundled_tui_path().is_some() || explicit_entry_path().is_none(); let command = if let Some(bundle) = if prefer_bundle { @@ -259,6 +267,47 @@ mod tests { assert!(!dependencies_are_available(&entry)); } + #[test] + fn interactive_gate_rejects_json_and_once() { + // The `--json` and `--once` bypass paths never launch OpenTUI, regardless + // of TTY state, so the OpenTUI app's own `watch --once --json` subprocess + // always falls through to the plain Rust path (no launch recursion). + assert!(!interactive_launch_ready(true, false)); + assert!(!interactive_launch_ready(false, true)); + } + + #[test] + fn interactive_gate_respects_disable_env() { + unsafe { + std::env::set_var("TSQ_OPENTUI_DISABLE", "1"); + } + let disabled = interactive_launch_ready(false, false); + unsafe { + std::env::remove_var("TSQ_OPENTUI_DISABLE"); + } + assert!(!disabled); + } + + #[test] + fn assignee_env_set_when_present() { + let mut command = Command::new("true"); + apply_assignee_env(&mut command, Some("alice")); + let key = std::ffi::OsStr::new("TSQ_TUI_ASSIGNEE"); + let found = command.get_envs().find(|(name, _)| *name == key); + assert_eq!(found, Some((key, Some(std::ffi::OsStr::new("alice"))))); + } + + #[test] + fn assignee_env_cleared_when_absent() { + let mut command = Command::new("true"); + apply_assignee_env(&mut command, None); + // `env_remove` records an explicit removal (key -> None) so the child + // cannot inherit a stale TSQ_TUI_ASSIGNEE from the parent environment. + let key = std::ffi::OsStr::new("TSQ_TUI_ASSIGNEE"); + let found = command.get_envs().find(|(name, _)| *name == key); + assert_eq!(found, Some((key, None))); + } + #[test] fn resolves_explicit_bundled_tui_binary() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/tui-opentui/src/watch-app.tsx b/tui-opentui/src/watch-app.tsx index 7625645..8b07787 100644 --- a/tui-opentui/src/watch-app.tsx +++ b/tui-opentui/src/watch-app.tsx @@ -17,6 +17,7 @@ import { filterLabel, metaBadge, readWatchTreeFlag, + watchListRowBudget, } from "./watch-helpers"; export function WatchApp() { @@ -88,12 +89,7 @@ export function WatchApp() { }, [rows.length]); const contentWidth = Math.max(40, dimensions.width - 6); - // Reserve every non-list row so the selected item never clips off the bottom: - // root padding 2 + header (border 2 + 3 lines, +1 when a warning shows) - // + list marginTop 1 + list (border 2 + padding 2) + footer marginTop 1 - // + footer (border 2 + 1 line) = 16, or 17 with the warning line. - const chromeRows = 16 + (warning ? 1 : 0); - const rowBudget = Math.max(3, dimensions.height - chromeRows); + const rowBudget = watchListRowBudget(dimensions.height, Boolean(warning)); const [start, end] = visibleRange(selectedIndex, rows.length, rowBudget); const visibleRows = rows.slice(start, end); diff --git a/tui-opentui/src/watch-helpers.ts b/tui-opentui/src/watch-helpers.ts index 6450b50..eaa420c 100644 --- a/tui-opentui/src/watch-helpers.ts +++ b/tui-opentui/src/watch-helpers.ts @@ -42,3 +42,15 @@ export function metaBadge(task: TasqueTask): string { export function readWatchTreeFlag(): boolean { return process.env.TSQ_WATCH_TREE?.trim() === "1"; } + +// Rows of fixed vertical chrome around the watch list, so the visible-row budget +// tracks the real rendered height and the selected row never clips off-screen. +// Keeping this named and tested guards against the off-by-N class of bug that +// caused an earlier clipping regression. +// root padding 2 + header (border 2 + 3 lines, +1 when a warning shows) +// + list marginTop 1 + list (border 2 + padding 2) + footer marginTop 1 +// + footer (border 2 + 1 line) = 16, or 17 with the warning line. +export function watchListRowBudget(height: number, hasWarning: boolean): number { + const chromeRows = 16 + (hasWarning ? 1 : 0); + return Math.max(3, height - chromeRows); +} diff --git a/tui-opentui/tests/watch-helpers.test.ts b/tui-opentui/tests/watch-helpers.test.ts index 474248e..accf784 100644 --- a/tui-opentui/tests/watch-helpers.test.ts +++ b/tui-opentui/tests/watch-helpers.test.ts @@ -4,6 +4,7 @@ import { buildWatchRows, filterLabel, metaBadge, + watchListRowBudget, } from "../src/watch-helpers"; function task(overrides: Partial & { id: string }): TasqueTask { @@ -92,6 +93,19 @@ describe("filterLabel", () => { }); }); +describe("watchListRowBudget", () => { + test("reserves 16 chrome rows without a warning", () => { + expect(watchListRowBudget(30, false)).toBe(14); + }); + test("reserves one extra row when a warning shows", () => { + expect(watchListRowBudget(30, true)).toBe(13); + }); + test("never drops below a 3-row floor on tiny terminals", () => { + expect(watchListRowBudget(10, false)).toBe(3); + expect(watchListRowBudget(4, true)).toBe(3); + }); +}); + describe("metaBadge", () => { test("priority only", () => { expect(metaBadge(task({ id: "tsq-1", priority: 3 }))).toBe("p3"); From aa05abe5730616aff6ca973f59d8211cb031c720 Mon Sep 17 00:00:00 2001 From: Aditya Sharma Date: Thu, 2 Jul 2026 14:22:25 -0700 Subject: [PATCH 4/4] style(watch): satisfy rustfmt in interactive_launch_ready --- src/cli/opentui.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/cli/opentui.rs b/src/cli/opentui.rs index e561604..32c61fb 100644 --- a/src/cli/opentui.rs +++ b/src/cli/opentui.rs @@ -28,10 +28,7 @@ fn interactive_launch_ready(json: bool, once: bool) -> bool { { return false; } - !json - && !once - && std::io::stdin().is_terminal() - && std::io::stdout().is_terminal() + !json && !once && std::io::stdin().is_terminal() && std::io::stdout().is_terminal() } fn launch_target_available() -> bool {