Skip to content

Commit cd12f2b

Browse files
committed
🐛 Fix tab handling in TUI and propagate CLI custom instructions
Add expand_tabs utility function that converts tab characters to spaces and strips control characters to prevent visual corruption in diff and code views. Apply this transformation in code_view and diff_view components before rendering. Also fix custom instructions (--instructions flag) not being passed through to the agent. The temp_instructions config field now properly flows to build_task_prompt in both execute_capability and streaming paths, and is applied to Studio's initial commit mode state.
1 parent 624644d commit cd12f2b

File tree

9 files changed

+82
-29
lines changed

9 files changed

+82
-29
lines changed

src/agents/iris.rs

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -666,21 +666,16 @@ Guidelines:
666666
if capability == "pr" || capability == "review" {
667667
if gitmoji_enabled {
668668
system_prompt.push_str("\n\n=== EMOJI STYLING ===\n");
669-
system_prompt.push_str(
670-
"Use emojis to make the output visually scannable and engaging:\n",
671-
);
672-
system_prompt.push_str(
673-
"- H1 title: ONE gitmoji at the start (✨, 🐛, ♻️, etc.)\n",
674-
);
669+
system_prompt
670+
.push_str("Use emojis to make the output visually scannable and engaging:\n");
671+
system_prompt.push_str("- H1 title: ONE gitmoji at the start (✨, 🐛, ♻️, etc.)\n");
675672
system_prompt.push_str(
676673
"- Section headers (## headings): Add relevant emojis (🎯 What's New, ⚙️ How It Works, 📋 Commits, ⚠️ Breaking Changes, 🧪 Testing, 📝 Notes)\n",
677674
);
678-
system_prompt.push_str(
679-
"- Commit list entries: Include the gitmoji from each commit\n",
680-
);
681-
system_prompt.push_str(
682-
"- Body text: Keep clean - no scattered emojis within prose\n\n",
683-
);
675+
system_prompt
676+
.push_str("- Commit list entries: Include the gitmoji from each commit\n");
677+
system_prompt
678+
.push_str("- Body text: Keep clean - no scattered emojis within prose\n\n");
684679
system_prompt.push_str("Choose from this gitmoji list:\n\n");
685680
system_prompt.push_str(&crate::gitmoji::get_gitmoji_list());
686681
} else if is_conventional {

src/agents/setup.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,12 @@ impl IrisAgentService {
244244
// Create the agent
245245
let mut agent = self.create_agent()?;
246246

247-
// Build task prompt with context information (no custom instructions)
248-
let task_prompt = Self::build_task_prompt(capability, &context, None);
247+
// Build task prompt with context information and any custom instructions from config
248+
let task_prompt = Self::build_task_prompt(
249+
capability,
250+
&context,
251+
self.config.temp_instructions.as_deref(),
252+
);
249253

250254
// Execute the task
251255
agent.execute_task(capability, &task_prompt).await
@@ -424,7 +428,11 @@ impl IrisAgentService {
424428
F: FnMut(&str, &str) + Send,
425429
{
426430
let mut agent = self.create_agent()?;
427-
let task_prompt = Self::build_task_prompt(capability, &context, None);
431+
let task_prompt = Self::build_task_prompt(
432+
capability,
433+
&context,
434+
self.config.temp_instructions.as_deref(),
435+
);
428436
agent
429437
.execute_task_streaming(capability, &task_prompt, on_chunk)
430438
.await

src/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1248,9 +1248,9 @@ async fn handle_pr_with_agent(
12481248
to: Option<String>,
12491249
repository_url: Option<String>,
12501250
) -> anyhow::Result<()> {
1251-
use arboard::Clipboard;
12521251
use crate::agents::{IrisAgentService, StructuredResponse, TaskContext};
12531252
use crate::instruction_presets::PresetType;
1253+
use arboard::Clipboard;
12541254

12551255
// Check if the preset is appropriate for PR descriptions (skip for raw output only)
12561256
if !raw

src/studio/app/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -821,8 +821,8 @@ impl StudioApp {
821821

822822
let handle = tokio::spawn(async move {
823823
let result = tokio::task::spawn_blocking(move || {
824-
use crate::companion::{BranchMemory, CompanionService};
825824
use super::state::CompanionSessionDisplay;
825+
use crate::companion::{BranchMemory, CompanionService};
826826

827827
// Create companion service (this is the slow part - file watcher setup)
828828
let service = CompanionService::new(repo_path, &branch)?;

src/studio/components/code_view.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use unicode_width::UnicodeWidthStr;
1515

1616
use super::syntax::SyntaxHighlighter;
1717
use crate::studio::theme;
18+
use crate::studio::utils::expand_tabs;
1819

1920
// ═══════════════════════════════════════════════════════════════════════════════
2021
// Code View State
@@ -269,6 +270,9 @@ fn render_code_line(
269270
selection: Option<(usize, usize)>,
270271
highlighter: Option<&SyntaxHighlighter>,
271272
) -> Line<'static> {
273+
// Expand tabs and strip control characters to prevent visual corruption
274+
let content = expand_tabs(content, 4);
275+
272276
let is_selected = line_num == selected_line;
273277
let is_in_selection =
274278
selection.is_some_and(|(start, end)| line_num >= start && line_num <= end);
@@ -305,7 +309,7 @@ fn render_code_line(
305309

306310
// Add syntax-highlighted content
307311
if let Some(hl) = highlighter {
308-
let styled_spans = hl.highlight_line(content);
312+
let styled_spans = hl.highlight_line(&content);
309313
let mut display_width = 0;
310314

311315
for (style, text) in styled_spans {
@@ -375,7 +379,7 @@ fn render_code_line(
375379
}
376380
format!("{}...", truncated)
377381
} else {
378-
content.to_string()
382+
content.clone()
379383
};
380384

381385
spans.push(Span::styled(display_content, content_style));

src/studio/components/diff_view.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
//! Displays git diffs with syntax highlighting for added/removed lines.
44
55
use crate::studio::theme;
6-
use crate::studio::utils::truncate_width;
6+
use crate::studio::utils::{expand_tabs, truncate_width};
77
use ratatui::Frame;
88
use ratatui::layout::Rect;
99
use ratatui::style::{Modifier, Style};
@@ -597,15 +597,17 @@ fn render_diff_line(line: &DiffLine, line_num_width: usize, width: usize) -> Lin
597597

598598
match line.line_type {
599599
DiffLineType::FileHeader => {
600-
let content = format!("━━━ {} ", line.content);
600+
let expanded = expand_tabs(&line.content, 4);
601+
let content = format!("━━━ {} ", expanded);
601602
let truncated = truncate_width(&content, width);
602603
Line::from(vec![Span::styled(truncated, style)])
603604
}
604605
DiffLineType::HunkHeader => {
605606
// " " prefix takes line_num_width * 2 + 3
607+
let expanded = expand_tabs(&line.content, 4);
606608
let prefix_width = line_num_width * 2 + 4;
607609
let max_content = width.saturating_sub(prefix_width);
608-
let truncated = truncate_width(&line.content, max_content);
610+
let truncated = truncate_width(&expanded, max_content);
609611
Line::from(vec![
610612
Span::styled(
611613
format!("{:>width$} ", "", width = line_num_width * 2 + 3),
@@ -636,11 +638,14 @@ fn render_diff_line(line: &DiffLine, line_num_width: usize, width: usize) -> Lin
636638
_ => theme::dimmed(),
637639
};
638640

641+
// Expand tabs to spaces for proper width calculation and rendering
642+
let expanded_content = expand_tabs(&line.content, 4);
643+
639644
// Calculate available width for content
640645
// Format: "XXXX │ XXXX +content"
641646
let fixed_width = line_num_width * 2 + 6; // " │ " (3) + " " (1) + prefix (1) + padding (1)
642647
let max_content = width.saturating_sub(fixed_width);
643-
let truncated = truncate_width(&line.content, max_content);
648+
let truncated = truncate_width(&expanded_content, max_content);
644649

645650
Line::from(vec![
646651
Span::styled(old_num, theme::dimmed()),

src/studio/handlers/modals/ref_selector.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,8 @@ pub fn handle(state: &mut StudioState, key: KeyEvent) -> Vec<SideEffect> {
4848
// Determine which ref to use:
4949
// 1. If there's a matching filtered ref, use that
5050
// 2. Otherwise, if input is not empty, use it as a custom ref (e.g., HEAD~5)
51-
let ref_to_use: Option<String> = filtered
52-
.get(selected)
53-
.map(|s| (*s).clone())
54-
.or_else(|| {
51+
let ref_to_use: Option<String> =
52+
filtered.get(selected).map(|s| (*s).clone()).or_else(|| {
5553
if input.is_empty() {
5654
None
5755
} else {

src/studio/state/mod.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,10 @@ impl SettingsState {
548548
theme: theme_id,
549549
use_gitmoji: config.use_gitmoji,
550550
instruction_preset: config.instruction_preset.clone(),
551-
custom_instructions: config.instructions.clone(),
551+
custom_instructions: config
552+
.temp_instructions
553+
.clone()
554+
.unwrap_or_else(|| config.instructions.clone()),
552555
available_providers,
553556
available_themes,
554557
available_presets,
@@ -977,14 +980,23 @@ impl StudioState {
977980
/// Create new studio state
978981
/// Note: Companion service is initialized asynchronously via `load_companion_async()` in app for fast startup
979982
pub fn new(config: Config, repo: Option<Arc<GitRepo>>) -> Self {
983+
// Apply CLI overrides to commit mode
984+
let mut modes = ModeStates::default();
985+
if let Some(temp_instr) = &config.temp_instructions {
986+
modes.commit.custom_instructions.clone_from(temp_instr);
987+
}
988+
if let Some(temp_preset) = &config.temp_preset {
989+
modes.commit.preset.clone_from(temp_preset);
990+
}
991+
980992
Self {
981993
repo,
982994
git_status: GitStatus::default(),
983995
git_status_loading: false,
984996
config,
985997
active_mode: Mode::Explore,
986998
focused_panel: PanelId::Left,
987-
modes: ModeStates::default(),
999+
modes,
9881000
modal: None,
9891001
chat_state: ChatState::new(),
9901002
notifications: VecDeque::new(),

src/studio/utils.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,37 @@
44
55
use unicode_width::UnicodeWidthStr;
66

7+
// ═══════════════════════════════════════════════════════════════════════════════
8+
// Tab Expansion
9+
// ═══════════════════════════════════════════════════════════════════════════════
10+
11+
/// Expand tab characters to spaces and strip control characters.
12+
///
13+
/// Tabs are expanded to the next multiple of `tab_width` columns.
14+
/// Control characters (except tab) are stripped to prevent TUI corruption.
15+
/// This is essential for TUI rendering where tabs and control codes
16+
/// would otherwise cause misalignment or visual glitches.
17+
pub fn expand_tabs(s: &str, tab_width: usize) -> String {
18+
let mut result = String::with_capacity(s.len());
19+
let mut column = 0;
20+
21+
for ch in s.chars() {
22+
if ch == '\t' {
23+
// Calculate spaces needed to reach next tab stop
24+
let spaces = tab_width - (column % tab_width);
25+
result.push_str(&" ".repeat(spaces));
26+
column += spaces;
27+
} else if !ch.is_control() {
28+
// Non-control character: add to result
29+
result.push(ch);
30+
column += ch.to_string().width();
31+
}
32+
// Control characters (except tab) are silently stripped
33+
}
34+
35+
result
36+
}
37+
738
// ═══════════════════════════════════════════════════════════════════════════════
839
// String Truncation Utilities
940
// ═══════════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)