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).
563 lines
20 KiB
Rust
563 lines
20 KiB
Rust
//! SearchToolCallBlock - search/grep for pattern.
|
|
|
|
use ratatui::style::Modifier;
|
|
use ratatui::text::{Line, Span};
|
|
|
|
use crate::scrollback::block::BlockContent;
|
|
use crate::scrollback::types::{
|
|
AccentStyle, BlockBackground, BlockContext, BlockLine, BlockOutput, DisplayMode, Selectable,
|
|
};
|
|
use crate::theme::Theme;
|
|
|
|
use super::TOOL_HEADER_RANGE;
|
|
|
|
/// A single line match from search results.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SearchLineMatch {
|
|
/// Line number in the file.
|
|
pub line_number: usize,
|
|
/// Content of the matching line.
|
|
pub content: String,
|
|
}
|
|
|
|
/// A file with its line matches from search results.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SearchFileMatch {
|
|
/// Path to the file.
|
|
pub path: String,
|
|
/// Line matches within this file.
|
|
pub matches: Vec<SearchLineMatch>,
|
|
}
|
|
|
|
/// Output mode mirroring `OutputMode` from kigi-tools.
|
|
/// We keep our own copy to avoid pulling in that dependency.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum SearchOutputMode {
|
|
/// Matching lines with context (default).
|
|
#[default]
|
|
Content,
|
|
/// File paths only.
|
|
FilesWithMatches,
|
|
/// Match counts per file.
|
|
Count,
|
|
}
|
|
|
|
impl SearchOutputMode {
|
|
/// Parse from the rawInput `output_mode` string.
|
|
pub fn from_str_opt(s: Option<&str>) -> Self {
|
|
match s {
|
|
Some("files_with_matches") => Self::FilesWithMatches,
|
|
Some("count") => Self::Count,
|
|
_ => Self::Content,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extra metadata from `GrepSearchInput` — carried for display purposes.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct SearchInputMeta {
|
|
/// Search path (subdirectory), if not workspace root.
|
|
pub path: Option<String>,
|
|
/// Glob filter (e.g. `"*.rs"`).
|
|
pub glob: Option<String>,
|
|
/// Output mode.
|
|
pub output_mode: SearchOutputMode,
|
|
/// Case-insensitive search.
|
|
pub case_insensitive: bool,
|
|
/// File type filter (rg `--type`), e.g. `"rust"`.
|
|
pub file_type: Option<String>,
|
|
/// Multiline regex mode.
|
|
pub multiline: bool,
|
|
}
|
|
|
|
/// Search/grep tool call.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SearchToolCallBlock {
|
|
/// The search pattern.
|
|
pub pattern: String,
|
|
/// Total number of matches found.
|
|
pub match_count: usize,
|
|
/// Matches grouped by file (line-level matches).
|
|
pub file_matches: Vec<SearchFileMatch>,
|
|
/// File paths only (for `files_with_matches` output mode).
|
|
/// Used when `file_matches` is empty but results exist.
|
|
pub file_paths: Vec<String>,
|
|
/// Error message if the tool call failed (None = success).
|
|
pub error: Option<String>,
|
|
/// Extra metadata from the search input (path, glob, mode, etc.).
|
|
pub meta: SearchInputMeta,
|
|
/// When the tool started running (Phase 2: time tracking).
|
|
pub started_at: Option<std::time::Instant>,
|
|
/// Elapsed time in ms after completion (Phase 2: time tracking).
|
|
pub elapsed_ms: Option<i64>,
|
|
}
|
|
|
|
impl SearchToolCallBlock {
|
|
/// Create a new search block.
|
|
///
|
|
/// Pre-completed blocks have no meaningful local timing — `started_at`
|
|
/// is `None`. Timing is only set for blocks that enter a running UI
|
|
/// state (via `set_last_running(true)` in `ScrollbackState`).
|
|
pub fn new(pattern: impl Into<String>) -> Self {
|
|
Self {
|
|
pattern: pattern.into(),
|
|
match_count: 0,
|
|
file_matches: Vec::new(),
|
|
file_paths: Vec::new(),
|
|
error: None,
|
|
meta: SearchInputMeta::default(),
|
|
started_at: None,
|
|
elapsed_ms: None,
|
|
}
|
|
}
|
|
|
|
/// Set match count and file matches.
|
|
pub fn with_matches(mut self, match_count: usize, file_matches: Vec<SearchFileMatch>) -> Self {
|
|
self.match_count = match_count;
|
|
self.file_matches = file_matches;
|
|
self
|
|
}
|
|
|
|
/// Set error (marks as failed).
|
|
pub fn with_error(mut self, error: impl Into<String>) -> Self {
|
|
self.error = Some(error.into());
|
|
self
|
|
}
|
|
|
|
/// Check if successful (no error).
|
|
pub fn is_success(&self) -> bool {
|
|
self.error.is_none()
|
|
}
|
|
|
|
/// Set error (mutable) — compute elapsed time if not already set (Phase 2).
|
|
pub fn set_error(&mut self, error: Option<String>) {
|
|
if self.elapsed_ms.is_none()
|
|
&& let Some(start) = self.started_at
|
|
{
|
|
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
|
|
}
|
|
self.error = error;
|
|
}
|
|
|
|
/// Finalize elapsed time from `started_at`.
|
|
///
|
|
/// Idempotent: no-op if `started_at` is `None` (pre-completed block)
|
|
/// or if `elapsed_ms` is already set (already finalized).
|
|
pub fn finish(&mut self) {
|
|
if self.elapsed_ms.is_some() {
|
|
return;
|
|
}
|
|
if let Some(start) = self.started_at {
|
|
self.elapsed_ms = Some(start.elapsed().as_millis() as i64);
|
|
}
|
|
}
|
|
|
|
/// Get elapsed time in ms (Phase 2).
|
|
pub fn elapsed_ms(&self) -> Option<i64> {
|
|
match self.elapsed_ms {
|
|
Some(ms) => Some(ms),
|
|
None => self
|
|
.started_at
|
|
.map(|start| start.elapsed().as_millis() as i64),
|
|
}
|
|
}
|
|
|
|
/// Set file matches (mutable).
|
|
pub fn set_file_matches(&mut self, match_count: usize, file_matches: Vec<SearchFileMatch>) {
|
|
self.match_count = match_count;
|
|
self.file_matches = file_matches;
|
|
}
|
|
|
|
/// Build the match summary string, adapted by output mode.
|
|
///
|
|
/// - `Content`: `(3 matches in 2 files)` / `(1 match)` / `(no matches)`
|
|
/// - `FilesWithMatches`: `(3 files)` / `(1 file)` / `(no matches)`
|
|
/// - `Count`: `(42 matches across 5 files)` / `(no matches)`
|
|
fn match_summary(&self) -> String {
|
|
if self.match_count == 0 {
|
|
return match self.meta.output_mode {
|
|
SearchOutputMode::FilesWithMatches => "(no files)".to_string(),
|
|
_ => "(no matches)".to_string(),
|
|
};
|
|
}
|
|
match self.meta.output_mode {
|
|
SearchOutputMode::Content => {
|
|
let file_count = self.file_matches.len();
|
|
if file_count > 1 {
|
|
format!("({} matches in {} files)", self.match_count, file_count)
|
|
} else if self.match_count == 1 {
|
|
"(1 match)".to_string()
|
|
} else {
|
|
format!("({} matches)", self.match_count)
|
|
}
|
|
}
|
|
SearchOutputMode::FilesWithMatches => {
|
|
let n = self.match_count; // match_count = # of files in this mode
|
|
if n == 1 {
|
|
"(1 file)".to_string()
|
|
} else {
|
|
format!("({n} files)")
|
|
}
|
|
}
|
|
SearchOutputMode::Count => {
|
|
let file_count = self.file_paths.len().max(self.file_matches.len());
|
|
if file_count > 1 {
|
|
format!("({} matches across {} files)", self.match_count, file_count)
|
|
} else if self.match_count == 1 {
|
|
"(1 match)".to_string()
|
|
} else {
|
|
format!("({} matches)", self.match_count)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether the pattern is trivial (`"."` or empty) — meaning the glob
|
|
/// is the real search term when present.
|
|
fn is_trivial_pattern(&self) -> bool {
|
|
self.pattern.is_empty() || self.pattern == "."
|
|
}
|
|
|
|
/// Render the header line.
|
|
///
|
|
/// Three cases:
|
|
/// 1. Trivial pattern + glob → `Search glob in path (summary)`
|
|
/// glob is string-styled without quotes (it IS the search term).
|
|
/// 2. Real pattern + glob → `Search "pattern" in glob in path (summary)`
|
|
/// glob shown as path scope after first "in".
|
|
/// 3. No glob → `Search "pattern" in path (summary)`
|
|
fn header_line(
|
|
&self,
|
|
theme: &Theme,
|
|
muted: bool,
|
|
dim_details: bool,
|
|
width: Option<usize>,
|
|
) -> Line<'static> {
|
|
let text_style = if muted {
|
|
theme.muted()
|
|
} else {
|
|
theme.primary()
|
|
};
|
|
let bold_style = text_style.add_modifier(Modifier::BOLD);
|
|
let pattern_style = if muted {
|
|
theme.muted()
|
|
} else {
|
|
theme.fg(theme.accent_success)
|
|
};
|
|
let detail_style = if dim_details {
|
|
theme.dim()
|
|
} else {
|
|
theme.muted()
|
|
};
|
|
let path_style = if muted {
|
|
theme.muted()
|
|
} else {
|
|
theme.fg(theme.path)
|
|
};
|
|
|
|
let mut spans = vec![Span::styled("Search ".to_string(), bold_style)];
|
|
|
|
// Search term: either promoted glob or quoted pattern
|
|
if self.is_trivial_pattern()
|
|
&& let Some(ref glob) = self.meta.glob
|
|
{
|
|
// Case 1: glob IS the search term — no quotes, string-styled
|
|
spans.push(Span::styled(glob.to_string(), pattern_style));
|
|
} else {
|
|
// Cases 2 & 3: quoted regex pattern
|
|
spans.push(Span::styled(format!("{:?}", self.pattern), pattern_style));
|
|
|
|
// Case 2: glob shown as first "in" scope (string-styled, not path)
|
|
if let Some(ref glob) = self.meta.glob {
|
|
spans.push(Span::styled(" in ".to_string(), text_style));
|
|
spans.push(Span::styled(glob.to_string(), pattern_style));
|
|
}
|
|
}
|
|
|
|
// Path scope (always after glob if both present).
|
|
// When width-constrained, fish-shorten the path.
|
|
if let Some(ref path) = self.meta.path {
|
|
spans.push(Span::styled(" in ".to_string(), text_style));
|
|
if let Some(w) = width {
|
|
let used: usize = spans
|
|
.iter()
|
|
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
|
|
.sum();
|
|
let summary = format!(" {}", self.match_summary());
|
|
// Reserve space for summary; if path can't fit even without it, drop summary.
|
|
let path_budget = w.saturating_sub(used + summary.len());
|
|
let shortened = crate::render::tool_paths::shorten_path(path, path_budget);
|
|
spans.push(Span::styled(shortened, path_style));
|
|
} else {
|
|
spans.push(Span::styled(path.to_string(), path_style));
|
|
}
|
|
}
|
|
|
|
// Match summary — always last.
|
|
// When width-constrained, only include if there's room.
|
|
let summary = format!(" {}", self.match_summary());
|
|
if let Some(w) = width {
|
|
let used: usize = spans
|
|
.iter()
|
|
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
|
|
.sum();
|
|
if used + summary.len() <= w {
|
|
spans.push(Span::styled(summary, detail_style));
|
|
}
|
|
} else {
|
|
spans.push(Span::styled(summary, detail_style));
|
|
}
|
|
|
|
let line = Line::from(spans);
|
|
if let Some(w) = width {
|
|
crate::render::line_utils::truncate_line(line, w)
|
|
} else {
|
|
line
|
|
}
|
|
}
|
|
|
|
/// Textable operand shown in the header (glob when it replaces a trivial pattern).
|
|
fn header_selection_text(&self) -> String {
|
|
if self.is_trivial_pattern()
|
|
&& let Some(ref glob) = self.meta.glob
|
|
{
|
|
glob.clone()
|
|
} else {
|
|
self.pattern.clone()
|
|
}
|
|
}
|
|
|
|
/// Header line with only the search term span selectable (exclude "Search " prefix).
|
|
///
|
|
/// Span 0 is always the label; span 1 is the pattern/glob. Later "in path"
|
|
/// and summary spans stay non-selectable so copy yields the search term.
|
|
fn header_block_line(&self, line: Line<'static>) -> BlockLine {
|
|
let term_end = 2.min(line.spans.len()).max(1);
|
|
BlockLine {
|
|
selectable: Selectable::Spans(1..term_end),
|
|
selection_range: Some(TOOL_HEADER_RANGE),
|
|
selection_text: Some(self.header_selection_text()),
|
|
content: line,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Build a single comma-separated metadata line.
|
|
///
|
|
/// Always present (at minimum shows `mode: pattern`).
|
|
/// Glob is never shown here (always inline in header).
|
|
/// All flags use `key: value` form. Values in primary fg, keys in muted.
|
|
fn metadata_line(&self, theme: &Theme) -> Line<'static> {
|
|
let label_style = theme.muted();
|
|
let value_style = theme.primary();
|
|
|
|
let mut parts: Vec<Vec<Span<'static>>> = Vec::new();
|
|
|
|
// Mode is always first — grounds the user in what kind of search this is.
|
|
let mode_str = match self.meta.output_mode {
|
|
SearchOutputMode::Content => "pattern",
|
|
SearchOutputMode::FilesWithMatches => "files",
|
|
SearchOutputMode::Count => "count",
|
|
};
|
|
parts.push(vec![
|
|
Span::styled("mode: ", label_style),
|
|
Span::styled(mode_str.to_string(), value_style),
|
|
]);
|
|
|
|
if let Some(ref ft) = self.meta.file_type {
|
|
parts.push(vec![
|
|
Span::styled("type: ", label_style),
|
|
Span::styled(ft.to_string(), value_style),
|
|
]);
|
|
}
|
|
if self.meta.case_insensitive {
|
|
parts.push(vec![
|
|
Span::styled("case-insensitive: ", label_style),
|
|
Span::styled("true", value_style),
|
|
]);
|
|
}
|
|
if self.meta.multiline {
|
|
parts.push(vec![
|
|
Span::styled("multiline: ", label_style),
|
|
Span::styled("true", value_style),
|
|
]);
|
|
}
|
|
|
|
let indent = " ";
|
|
let mut spans: Vec<Span<'static>> = vec![Span::styled(indent.to_string(), label_style)];
|
|
for (i, part) in parts.into_iter().enumerate() {
|
|
if i > 0 {
|
|
spans.push(Span::styled(", ", label_style));
|
|
}
|
|
spans.extend(part);
|
|
}
|
|
|
|
Line::from(spans)
|
|
}
|
|
}
|
|
|
|
impl BlockContent for SearchToolCallBlock {
|
|
fn output(&self, ctx: &BlockContext) -> BlockOutput {
|
|
let theme = Theme::current();
|
|
let tool_cfg = &ctx.appearance.scrollback.blocks.tool;
|
|
let muted_collapsed = ctx.mute_when_collapsed(tool_cfg.muted_collapsed);
|
|
let dim_details = tool_cfg.dim_details;
|
|
|
|
match ctx.mode {
|
|
DisplayMode::Collapsed => BlockOutput {
|
|
lines: vec![self.header_block_line(self.header_line(
|
|
&theme,
|
|
muted_collapsed,
|
|
dim_details,
|
|
Some(ctx.content_width()),
|
|
))],
|
|
},
|
|
DisplayMode::Truncated | DisplayMode::Expanded => {
|
|
let mut lines: Vec<BlockLine> = vec![self.header_block_line(self.header_line(
|
|
&theme,
|
|
false,
|
|
dim_details,
|
|
None,
|
|
))];
|
|
|
|
// Metadata line (mode + non-default input fields, comma-separated).
|
|
// Blank line separates header from metadata.
|
|
lines.push(BlockLine::separator(Line::from("")));
|
|
lines.push(BlockLine::separator(self.metadata_line(&theme)));
|
|
|
|
let has_results = !self.file_matches.is_empty() || !self.file_paths.is_empty();
|
|
|
|
if has_results {
|
|
// Blank line before results
|
|
lines.push(Line::from("").into());
|
|
} else if self.match_count == 0 {
|
|
// No results — show a hint
|
|
lines.push(Line::from("").into());
|
|
lines.push(
|
|
Line::from(Span::styled(" (no results)".to_string(), theme.muted()))
|
|
.into(),
|
|
);
|
|
}
|
|
|
|
if !self.file_matches.is_empty() {
|
|
// Line-level matches (content mode).
|
|
// Each file group is a separate bg_dark block, separated
|
|
// by a blank line.
|
|
let indent = " ";
|
|
let match_indent = " ";
|
|
|
|
for (i, file_match) in self.file_matches.iter().enumerate() {
|
|
if i > 0 {
|
|
// Blank line between file groups
|
|
lines.push(Line::from("").into());
|
|
}
|
|
|
|
// File path line
|
|
lines.push(
|
|
BlockLine::from(Line::from(Span::styled(
|
|
format!("{}{}", indent, file_match.path),
|
|
theme.fg(theme.path),
|
|
)))
|
|
.with_panel_background(theme.bg_dark),
|
|
);
|
|
|
|
// Match lines: " 42 content..."
|
|
for m in &file_match.matches {
|
|
let line_num_str = format!("{:>4}", m.line_number);
|
|
let content_trimmed = m.content.trim_end();
|
|
lines.push(
|
|
BlockLine::from(Line::from(vec![
|
|
Span::styled(match_indent.to_string(), theme.primary()),
|
|
Span::styled(line_num_str, theme.muted()),
|
|
Span::styled(" ".to_string(), theme.primary()),
|
|
Span::styled(content_trimmed.to_string(), theme.primary()),
|
|
]))
|
|
.with_panel_background(theme.bg_dark),
|
|
);
|
|
}
|
|
}
|
|
} else if !self.file_paths.is_empty() {
|
|
// File paths only (files_with_matches mode) OR
|
|
// count mode (path:N lines).
|
|
let indent = " ";
|
|
let is_count = self.meta.output_mode == SearchOutputMode::Count;
|
|
|
|
for path in &self.file_paths {
|
|
let line = if is_count {
|
|
// Count mode: "path:N" — split at last ':',
|
|
// path part in path color, ":N" in normal fg.
|
|
if let Some(colon_pos) = path.rfind(':') {
|
|
let file_part = &path[..colon_pos];
|
|
let count_part = &path[colon_pos..]; // includes ':'
|
|
Line::from(vec![
|
|
Span::styled(
|
|
format!("{indent}{file_part}"),
|
|
theme.fg(theme.path),
|
|
),
|
|
Span::styled(count_part.to_string(), theme.primary()),
|
|
])
|
|
} else {
|
|
Line::from(Span::styled(
|
|
format!("{indent}{path}"),
|
|
theme.fg(theme.path),
|
|
))
|
|
}
|
|
} else {
|
|
Line::from(Span::styled(
|
|
format!("{indent}{path}"),
|
|
theme.fg(theme.path),
|
|
))
|
|
};
|
|
|
|
lines.push(BlockLine::from(line).with_panel_background(theme.bg_dark));
|
|
}
|
|
}
|
|
|
|
BlockOutput { lines }
|
|
}
|
|
}
|
|
}
|
|
|
|
fn accent(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
|
|
None // Search blocks never have an accent line
|
|
}
|
|
|
|
fn bullet(&self, _ctx: &BlockContext) -> Option<AccentStyle> {
|
|
if self.error.is_some() {
|
|
let theme = Theme::current();
|
|
Some(AccentStyle::static_color(theme.accent_error))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn has_vpad(&self, _ctx: &BlockContext) -> bool {
|
|
false
|
|
}
|
|
|
|
fn background(&self, _ctx: &BlockContext) -> BlockBackground {
|
|
BlockBackground::None
|
|
}
|
|
|
|
fn has_raw_mode(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn is_foldable(&self) -> bool {
|
|
// Always foldable (even with no results — expand shows metadata
|
|
// and/or "(no results)" for consistency).
|
|
self.error.is_none()
|
|
}
|
|
|
|
fn default_display_mode(&self) -> DisplayMode {
|
|
DisplayMode::Collapsed
|
|
}
|
|
|
|
fn next_fold_mode(&self, current: DisplayMode, _is_running: bool) -> DisplayMode {
|
|
match current {
|
|
DisplayMode::Collapsed => DisplayMode::Expanded,
|
|
_ => DisplayMode::Collapsed,
|
|
}
|
|
}
|
|
}
|