M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
//! BgTaskBlock — scrollback entries for background task lifecycle.
|
||||
//!
|
||||
//! Three kinds: Started, Completed, Failed. All render as always-collapsed,
|
||||
//! groupable blocks with dimmed colored bullets (same dimming as execute blocks).
|
||||
//! Enter / Ctrl-F opens block viewer with stdout from central store.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
|
||||
use crate::render::color::blend_color;
|
||||
use crate::scrollback::block::BlockContent;
|
||||
use crate::scrollback::types::{AccentStyle, BlockContext, BlockOutput, DisplayMode};
|
||||
use crate::theme::Theme;
|
||||
use crate::util::format_duration;
|
||||
|
||||
/// What kind of bg task lifecycle event this block represents.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BgTaskKind {
|
||||
/// Task was started (process is running).
|
||||
Started,
|
||||
/// Task completed successfully.
|
||||
Completed { elapsed: Duration },
|
||||
/// Task failed (non-zero exit, signal, timeout, etc.).
|
||||
Failed {
|
||||
elapsed: Duration,
|
||||
exit_code: Option<i32>,
|
||||
signal: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Background task scrollback block.
|
||||
///
|
||||
/// Always collapsed, not foldable, groupable, selectable.
|
||||
/// Enter / Ctrl-F opens block viewer with stdout from the central store.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BgTaskBlock {
|
||||
/// The command that was run.
|
||||
pub command: String,
|
||||
/// Background task ID (for looking up stdout in central store).
|
||||
pub task_id: String,
|
||||
/// Lifecycle kind (started / completed / failed).
|
||||
pub kind: BgTaskKind,
|
||||
/// Optional description (from the tool call's description field).
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl BgTaskBlock {
|
||||
/// Create a "Task started" block.
|
||||
pub fn started(command: impl Into<String>, task_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
command: command.into(),
|
||||
task_id: task_id.into(),
|
||||
kind: BgTaskKind::Started,
|
||||
description: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a "Task completed" block.
|
||||
pub fn completed(
|
||||
command: impl Into<String>,
|
||||
task_id: impl Into<String>,
|
||||
elapsed: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
command: command.into(),
|
||||
task_id: task_id.into(),
|
||||
kind: BgTaskKind::Completed { elapsed },
|
||||
description: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a "Task failed" block.
|
||||
pub fn failed(
|
||||
command: impl Into<String>,
|
||||
task_id: impl Into<String>,
|
||||
elapsed: Duration,
|
||||
exit_code: Option<i32>,
|
||||
signal: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
command: command.into(),
|
||||
task_id: task_id.into(),
|
||||
kind: BgTaskKind::Failed {
|
||||
elapsed,
|
||||
exit_code,
|
||||
signal,
|
||||
},
|
||||
description: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the description (builder pattern).
|
||||
pub fn with_description(mut self, description: Option<String>) -> Self {
|
||||
self.description = description;
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether this block represents a running task (Started kind).
|
||||
pub fn is_running(&self) -> bool {
|
||||
matches!(self.kind, BgTaskKind::Started)
|
||||
}
|
||||
|
||||
/// Mark a Started block as completed (called when task finishes).
|
||||
pub fn mark_completed(&mut self, elapsed: Duration) {
|
||||
self.kind = BgTaskKind::Completed { elapsed };
|
||||
}
|
||||
|
||||
/// Mark a Started block as failed (called when task fails).
|
||||
pub fn mark_failed(
|
||||
&mut self,
|
||||
elapsed: Duration,
|
||||
exit_code: Option<i32>,
|
||||
signal: Option<String>,
|
||||
) {
|
||||
self.kind = BgTaskKind::Failed {
|
||||
elapsed,
|
||||
exit_code,
|
||||
signal,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockContent for BgTaskBlock {
|
||||
fn output(&self, ctx: &BlockContext) -> BlockOutput {
|
||||
let theme = Theme::current();
|
||||
// When selected, lift only the bold "Task" label to `text_primary`
|
||||
// so it reads as undimmed (mirrors `read.rs` / `search.rs`, which
|
||||
// bump only the label and leave the rest at `muted`). The detail
|
||||
// text (verb + description) stays muted in every state.
|
||||
let bold = if ctx.is_selected {
|
||||
theme.primary().add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
theme.muted().add_modifier(Modifier::BOLD)
|
||||
};
|
||||
let muted = theme.muted();
|
||||
|
||||
// Collapse newlines for single-line display (ratatui drops '\n' as zero-width,
|
||||
// merging adjacent lines without spacing).
|
||||
let command = self.command.replace('\n', " ");
|
||||
|
||||
// Prefer description over raw command for the collapsed one-line display.
|
||||
// The full command is always available in the block viewer (preamble).
|
||||
let display = match &self.description {
|
||||
Some(d) if !d.trim().is_empty() => d.replace('\n', " "),
|
||||
_ => command,
|
||||
};
|
||||
let line = match &self.kind {
|
||||
BgTaskKind::Started => Line::from(vec![
|
||||
Span::styled("Task ", bold),
|
||||
Span::styled("started: ", muted),
|
||||
Span::styled(display, muted),
|
||||
]),
|
||||
BgTaskKind::Completed { elapsed } => Line::from(vec![
|
||||
Span::styled("Task ", bold),
|
||||
Span::styled(
|
||||
format!("completed in {}: ", format_duration(*elapsed)),
|
||||
muted,
|
||||
),
|
||||
Span::styled(display, muted),
|
||||
]),
|
||||
BgTaskKind::Failed {
|
||||
elapsed,
|
||||
exit_code,
|
||||
signal,
|
||||
} => {
|
||||
// Detect kill signals to show "killed" instead of "failed"
|
||||
let is_killed = signal
|
||||
.as_deref()
|
||||
.is_some_and(|s| matches!(s, "killed" | "SIGTERM" | "SIGKILL" | "oom"));
|
||||
let verb = if is_killed { "killed" } else { "failed" };
|
||||
let detail = if is_killed {
|
||||
String::new()
|
||||
} else {
|
||||
match (exit_code, signal) {
|
||||
(_, Some(sig)) => format!(" ({})", sig),
|
||||
(Some(code), None) => format!(" (exit {})", code),
|
||||
(None, None) => String::new(),
|
||||
}
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled("Task ", bold),
|
||||
Span::styled(format!("{verb} in {}: ", format_duration(*elapsed)), muted),
|
||||
Span::styled(format!("{}{}", display, detail), muted),
|
||||
])
|
||||
}
|
||||
};
|
||||
|
||||
BlockOutput {
|
||||
lines: vec![line.into()],
|
||||
}
|
||||
}
|
||||
|
||||
fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
|
||||
let theme = Theme::current();
|
||||
match &self.kind {
|
||||
BgTaskKind::Started if ctx.is_running => {
|
||||
Some(AccentStyle::static_color(theme.accent_running))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
|
||||
let theme = Theme::current();
|
||||
match &self.kind {
|
||||
BgTaskKind::Started => {
|
||||
if ctx.is_running {
|
||||
// Animated pulse between bg and dimmed magenta.
|
||||
// Pre-dim using the same ratio as collapsed execute bullets
|
||||
// so the peak brightness matches other collapsed blocks.
|
||||
let dim = ctx.appearance.scrollback.display.dim_accent;
|
||||
let dimmed = blend_color(theme.bg_base, theme.accent_running, dim)
|
||||
.unwrap_or(theme.accent_running);
|
||||
Some(AccentStyle::animated(dimmed))
|
||||
} else {
|
||||
// Normal gray after finish_running() is called
|
||||
None
|
||||
}
|
||||
}
|
||||
BgTaskKind::Completed { .. } => Some(AccentStyle::static_color(theme.accent_success)),
|
||||
BgTaskKind::Failed { .. } => Some(AccentStyle::static_color(theme.accent_error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn has_raw_mode(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_foldable(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_display_mode(&self) -> DisplayMode {
|
||||
DisplayMode::Collapsed
|
||||
}
|
||||
|
||||
fn is_selectable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn has_bullet(&self, _ctx: &BlockContext) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_groupable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn preamble(&self, ctx: &BlockContext) -> Option<Text<'static>> {
|
||||
let theme = Theme::current();
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Description first (primary text), then a blank separator, then
|
||||
// the `$ command` with bash syntax highlighting. When there is no
|
||||
// description, the command stands alone with no leading blank row.
|
||||
let description = self
|
||||
.description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(desc) = description {
|
||||
// Trim trailing whitespace per line and collapse runs of blank
|
||||
// lines to a single blank — multi-line descriptions can carry
|
||||
// noisy internal blank rows that would otherwise stretch the
|
||||
// preamble.
|
||||
let mut prev_blank = false;
|
||||
for line in desc.lines() {
|
||||
let trimmed = line.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
if prev_blank {
|
||||
continue;
|
||||
}
|
||||
prev_blank = true;
|
||||
} else {
|
||||
prev_blank = false;
|
||||
}
|
||||
lines.push(Line::from(Span::styled(
|
||||
trimmed.to_string(),
|
||||
theme.primary(),
|
||||
)));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Multi-line `$ command` must be separate ratatui Lines. A single Line
|
||||
// drops '\n' as zero-width, which smashes `cmd1\ncmd2` into `cmd1cmd2`
|
||||
// (visible when expanding a started bg task in the block viewer).
|
||||
// Match execute / permission-panel soft-wrap so physical newlines and
|
||||
// long lines render the same way as foreground shell tool calls.
|
||||
push_shell_command_preamble_lines(&mut lines, &self.command, ctx.width as usize, &theme);
|
||||
|
||||
Some(Text::from(lines))
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a soft-wrapped `$ command` block to `lines` (first row prefixed with
|
||||
/// `$ `, continuations hang-indented under the command body).
|
||||
fn push_shell_command_preamble_lines(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
command: &str,
|
||||
width: usize,
|
||||
theme: &Theme,
|
||||
) {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
let prefix = "$ ";
|
||||
let hang = UnicodeWidthStr::width(prefix);
|
||||
let cmd_width = width.saturating_sub(hang).max(1);
|
||||
|
||||
let command = if command.trim().is_empty() {
|
||||
"\u{2026}"
|
||||
} else {
|
||||
command
|
||||
};
|
||||
|
||||
let cmd_rows =
|
||||
crate::views::permission_view::render_bash_command_display_lines(command, cmd_width);
|
||||
|
||||
let hang_indent: String = " ".repeat(hang);
|
||||
if cmd_rows.is_empty() {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
prefix.to_string(),
|
||||
theme.dim(),
|
||||
)]));
|
||||
return;
|
||||
}
|
||||
for (i, row) in cmd_rows.into_iter().enumerate() {
|
||||
let mut spans = if i == 0 {
|
||||
vec![Span::styled(prefix.to_string(), theme.dim())]
|
||||
} else {
|
||||
vec![Span::raw(hang_indent.clone())]
|
||||
};
|
||||
spans.extend(row.spans);
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::appearance::AppearanceConfig;
|
||||
|
||||
fn test_ctx() -> BlockContext {
|
||||
BlockContext {
|
||||
mode: DisplayMode::Collapsed,
|
||||
is_running: false,
|
||||
width: 120,
|
||||
raw: false,
|
||||
max_lines: None,
|
||||
appearance: AppearanceConfig::default(),
|
||||
is_selected: false,
|
||||
cwd: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn line_text(block: &BgTaskBlock) -> String {
|
||||
block.output(&test_ctx()).lines[0]
|
||||
.content
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_command_collapses_newlines() {
|
||||
let block = BgTaskBlock::started("echo foo\necho bar", "t1");
|
||||
let text = line_text(&block);
|
||||
assert!(
|
||||
text.contains("foo echo bar"),
|
||||
"newlines should become spaces, got: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains('\n'),
|
||||
"output line must not contain literal newlines"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn started_and_completed_prefer_description_over_command() {
|
||||
let started = BgTaskBlock::started("sleep 20", "t1")
|
||||
.with_description(Some("Wait twenty seconds".into()));
|
||||
let started_text = line_text(&started);
|
||||
assert!(
|
||||
started_text.contains("Wait twenty seconds"),
|
||||
"started={started_text:?}"
|
||||
);
|
||||
assert!(
|
||||
!started_text.contains("sleep 20"),
|
||||
"started should not show raw command when description present: {started_text:?}"
|
||||
);
|
||||
|
||||
let completed = BgTaskBlock::completed("sleep 20", "t1", Duration::from_secs(20))
|
||||
.with_description(Some("Wait twenty seconds".into()));
|
||||
let completed_text = line_text(&completed);
|
||||
assert!(
|
||||
completed_text.contains("completed"),
|
||||
"completed={completed_text:?}"
|
||||
);
|
||||
assert!(
|
||||
completed_text.contains("Wait twenty seconds"),
|
||||
"completed={completed_text:?}"
|
||||
);
|
||||
assert!(
|
||||
!completed_text.contains("sleep 20"),
|
||||
"completed should not show raw command when description present: {completed_text:?}"
|
||||
);
|
||||
|
||||
let failed = BgTaskBlock::failed("sleep 20", "t1", Duration::from_secs(1), Some(1), None)
|
||||
.with_description(Some("Wait twenty seconds".into()));
|
||||
let failed_text = line_text(&failed);
|
||||
assert!(
|
||||
failed_text.contains("Wait twenty seconds"),
|
||||
"failed={failed_text:?}"
|
||||
);
|
||||
assert!(!failed_text.contains("sleep 20"), "failed={failed_text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_multiline_command_collapses_newlines() {
|
||||
let block = BgTaskBlock::completed("cmd1\ncmd2", "t1", std::time::Duration::from_secs(1));
|
||||
let output = block.output(&test_ctx());
|
||||
let text: String = output.lines[0]
|
||||
.content
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect();
|
||||
assert!(
|
||||
text.contains("cmd1 cmd2"),
|
||||
"newlines should become spaces in completed variant, got: {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn preamble_plain(block: &BgTaskBlock) -> Vec<String> {
|
||||
let text = block.preamble(&test_ctx()).expect("preamble");
|
||||
text.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preamble_description_first_then_blank_then_command() {
|
||||
let block = BgTaskBlock::started("cargo test --release", "t1")
|
||||
.with_description(Some("Run release tests".into()));
|
||||
let plain = preamble_plain(&block);
|
||||
assert_eq!(plain.len(), 3, "expected description + blank + command");
|
||||
assert_eq!(plain[0], "Run release tests");
|
||||
assert_eq!(plain[1], "");
|
||||
assert_eq!(plain[2], "$ cargo test --release");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preamble_uses_primary_text_color_for_description() {
|
||||
// Pin theme to avoid races with parallel tests that call `cache::set`.
|
||||
crate::theme::cache::set(crate::theme::ThemeKind::GrokNight);
|
||||
let block =
|
||||
BgTaskBlock::started("ls", "t1").with_description(Some("List the files".into()));
|
||||
let text = block.preamble(&test_ctx()).expect("preamble");
|
||||
let theme = Theme::current();
|
||||
let span = &text.lines[0].spans[0];
|
||||
assert_eq!(span.content.as_ref(), "List the files");
|
||||
assert_eq!(span.style.fg, Some(theme.text_primary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preamble_without_description_renders_only_command() {
|
||||
let block = BgTaskBlock::started("ls -la", "t1");
|
||||
let plain = preamble_plain(&block);
|
||||
assert_eq!(plain.len(), 1);
|
||||
assert_eq!(plain[0], "$ ls -la");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preamble_blank_description_falls_back_to_command_only() {
|
||||
let block = BgTaskBlock::started("ls", "t1").with_description(Some(" \n ".into()));
|
||||
let plain = preamble_plain(&block);
|
||||
assert_eq!(plain.len(), 1);
|
||||
assert_eq!(plain[0], "$ ls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preamble_multiline_description_keeps_separate_lines() {
|
||||
let block = BgTaskBlock::started("ls", "t1")
|
||||
.with_description(Some("First line\nSecond line".into()));
|
||||
let plain = preamble_plain(&block);
|
||||
assert_eq!(plain.len(), 4);
|
||||
assert_eq!(plain[0], "First line");
|
||||
assert_eq!(plain[1], "Second line");
|
||||
assert_eq!(plain[2], "");
|
||||
assert_eq!(plain[3], "$ ls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preamble_collapses_blank_run_and_trims_trailing_whitespace() {
|
||||
// Runs of blank lines collapse to one; trailing whitespace on a
|
||||
// line is stripped so the rendered row doesn't carry stray spaces.
|
||||
let block = BgTaskBlock::started("ls", "t1")
|
||||
.with_description(Some("First \n\n\n\nSecond ".into()));
|
||||
let plain = preamble_plain(&block);
|
||||
// First, single blank (collapsed from 3 internal blanks), Second,
|
||||
// separator blank, command.
|
||||
assert_eq!(plain, vec!["First", "", "Second", "", "$ ls"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preamble_multiline_command_keeps_separate_lines() {
|
||||
// Regression: a single ratatui Line drops '\n' as zero-width, which
|
||||
// smashed multi-line bg-task commands into one unreadable blob when
|
||||
// expanded in the block viewer.
|
||||
let block = BgTaskBlock::started(
|
||||
"export XAI_ROOT=/tmp\ncd /tmp\necho start\nprod-run start backend",
|
||||
"t1",
|
||||
)
|
||||
.with_description(Some("Start backend".into()));
|
||||
let plain = preamble_plain(&block);
|
||||
assert_eq!(
|
||||
plain,
|
||||
vec![
|
||||
"Start backend",
|
||||
"",
|
||||
"$ export XAI_ROOT=/tmp",
|
||||
" cd /tmp",
|
||||
" echo start",
|
||||
" prod-run start backend",
|
||||
]
|
||||
);
|
||||
// Sanity: must not be the smashed single-line form.
|
||||
let joined = plain.join("");
|
||||
assert!(
|
||||
!joined.contains("/tmpcd") && !joined.contains("/tmpecho"),
|
||||
"newlines must not be dropped: {plain:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preamble_multiline_command_without_description() {
|
||||
let block = BgTaskBlock::started("echo a\necho b", "t1");
|
||||
let plain = preamble_plain(&block);
|
||||
assert_eq!(plain, vec!["$ echo a", " echo b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_multiline_command_collapses_newlines() {
|
||||
let block = BgTaskBlock::failed(
|
||||
"a\nb",
|
||||
"t1",
|
||||
std::time::Duration::from_secs(2),
|
||||
Some(1),
|
||||
None,
|
||||
);
|
||||
let output = block.output(&test_ctx());
|
||||
let text: String = output.lines[0]
|
||||
.content
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect();
|
||||
assert!(
|
||||
text.contains("a b"),
|
||||
"newlines should become spaces in failed variant, got: {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user