@@ -19,6 +19,19 @@ use tokio::sync::Mutex;
1919/// Maximum lines to show in preview before collapsing
2020const PREVIEW_LINES : usize = 4 ;
2121
22+ /// Safely truncate a string to a maximum character count, handling UTF-8 properly.
23+ /// Adds "..." suffix when truncation occurs.
24+ fn truncate_safe ( s : & str , max_chars : usize ) -> String {
25+ let char_count = s. chars ( ) . count ( ) ;
26+ if char_count <= max_chars {
27+ s. to_string ( )
28+ } else {
29+ let truncate_to = max_chars. saturating_sub ( 3 ) ;
30+ let truncated: String = s. chars ( ) . take ( truncate_to) . collect ( ) ;
31+ format ! ( "{}..." , truncated)
32+ }
33+ }
34+
2235/// Tool call state with full output for expansion
2336#[ derive( Debug , Clone ) ]
2437pub struct ToolCallState {
@@ -620,6 +633,34 @@ fn print_tool_result(name: &str, args: &str, result: &str) -> (bool, Vec<String>
620633 }
621634 } ) ;
622635
636+ // If parsing failed, check if it's a tool error message
637+ // Tool errors come through as plain strings like "Shell error: ..."
638+ let parsed = if parsed. is_err ( ) && !result. is_empty ( ) {
639+ // Check for common error patterns
640+ let is_tool_error = result. contains ( "error:" )
641+ || result. contains ( "Error:" )
642+ || result. starts_with ( "Shell error" )
643+ || result. starts_with ( "Toolset error" )
644+ || result. starts_with ( "ToolCallError" ) ;
645+
646+ if is_tool_error {
647+ // Wrap the error message in a JSON structure so formatters can handle it
648+ let clean_msg = result
649+ . replace ( "Toolset error: " , "" )
650+ . replace ( "ToolCallError: " , "" )
651+ . replace ( "Shell error: " , "" ) ;
652+ Ok ( serde_json:: json!( {
653+ "error" : true ,
654+ "message" : clean_msg,
655+ "success" : false
656+ } ) )
657+ } else {
658+ parsed
659+ }
660+ } else {
661+ parsed
662+ } ;
663+
623664 // Format output based on tool type
624665 let ( status_ok, output_lines) = match name {
625666 "shell" => format_shell_result ( & parsed) ,
@@ -790,6 +831,19 @@ fn format_shell_result(
790831 parsed : & Result < serde_json:: Value , serde_json:: Error > ,
791832) -> ( bool , Vec < String > ) {
792833 if let Ok ( v) = parsed {
834+ // Check if this is an error message (from tool error or blocked command)
835+ if let Some ( error_msg) = v. get ( "message" ) . and_then ( |m| m. as_str ( ) ) {
836+ if v. get ( "error" ) . and_then ( |e| e. as_bool ( ) ) . unwrap_or ( false ) {
837+ return ( false , vec ! [ error_msg. to_string( ) ] ) ;
838+ }
839+ }
840+
841+ // Check for cancelled or blocked operations (plan mode, user cancel)
842+ if v. get ( "cancelled" ) . and_then ( |c| c. as_bool ( ) ) . unwrap_or ( false ) {
843+ let reason = v. get ( "reason" ) . and_then ( |r| r. as_str ( ) ) . unwrap_or ( "cancelled" ) ;
844+ return ( false , vec ! [ reason. to_string( ) ] ) ;
845+ }
846+
793847 let success = v. get ( "success" ) . and_then ( |s| s. as_bool ( ) ) . unwrap_or ( false ) ;
794848 let stdout = v. get ( "stdout" ) . and_then ( |s| s. as_str ( ) ) . unwrap_or ( "" ) ;
795849 let stderr = v. get ( "stderr" ) . and_then ( |s| s. as_str ( ) ) . unwrap_or ( "" ) ;
@@ -1185,11 +1239,7 @@ fn format_hadolint_result(
11851239 if let Some ( quick_fixes) = v. get ( "quick_fixes" ) . and_then ( |q| q. as_array ( ) )
11861240 && let Some ( first_fix) = quick_fixes. first ( ) . and_then ( |f| f. as_str ( ) )
11871241 {
1188- let truncated = if first_fix. len ( ) > 70 {
1189- format ! ( "{}..." , & first_fix[ ..67 ] )
1190- } else {
1191- first_fix. to_string ( )
1192- } ;
1242+ let truncated = truncate_safe ( first_fix, 70 ) ;
11931243 lines. push ( format ! (
11941244 "{} → Fix: {}{}" ,
11951245 ansi:: INFO_BLUE ,
@@ -1233,11 +1283,7 @@ fn format_hadolint_issue(issue: &serde_json::Value, icon: &str, color: &str) ->
12331283 } ;
12341284
12351285 // Truncate message
1236- let msg_display = if message. len ( ) > 50 {
1237- format ! ( "{}..." , & message[ ..47 ] )
1238- } else {
1239- message. to_string ( )
1240- } ;
1286+ let msg_display = truncate_safe ( message, 50 ) ;
12411287
12421288 format ! (
12431289 "{}{} L{}:{} {}{}[{}]{} {} {}" ,
@@ -1284,11 +1330,7 @@ fn format_kubelint_result(
12841330 ) ) ;
12851331 for ( i, err) in errors. iter ( ) . take ( 3 ) . enumerate ( ) {
12861332 if let Some ( err_str) = err. as_str ( ) {
1287- let truncated = if err_str. len ( ) > 70 {
1288- format ! ( "{}..." , & err_str[ ..67 ] )
1289- } else {
1290- err_str. to_string ( )
1291- } ;
1333+ let truncated = truncate_safe ( err_str, 70 ) ;
12921334 lines. push ( format ! (
12931335 "{} {} {}{}" ,
12941336 ansi:: HIGH ,
@@ -1420,11 +1462,7 @@ fn format_kubelint_result(
14201462 if let Some ( quick_fixes) = v. get ( "quick_fixes" ) . and_then ( |q| q. as_array ( ) )
14211463 && let Some ( first_fix) = quick_fixes. first ( ) . and_then ( |f| f. as_str ( ) )
14221464 {
1423- let truncated = if first_fix. len ( ) > 70 {
1424- format ! ( "{}..." , & first_fix[ ..67 ] )
1425- } else {
1426- first_fix. to_string ( )
1427- } ;
1465+ let truncated = truncate_safe ( first_fix, 70 ) ;
14281466 lines. push ( format ! (
14291467 "{} → Fix: {}{}" ,
14301468 ansi:: INFO_BLUE ,
@@ -1450,8 +1488,6 @@ fn format_kubelint_result(
14501488 ( false , vec ! [ "kubelint analysis complete" . to_string( ) ] )
14511489 }
14521490}
1453-
1454- /// Format a single kubelint issue for display
14551491fn format_kubelint_issue ( issue : & serde_json:: Value , icon : & str , color : & str ) -> String {
14561492 let check = issue. get ( "check" ) . and_then ( |c| c. as_str ( ) ) . unwrap_or ( "?" ) ;
14571493 let message = issue. get ( "message" ) . and_then ( |m| m. as_str ( ) ) . unwrap_or ( "?" ) ;
@@ -1468,11 +1504,7 @@ fn format_kubelint_issue(issue: &serde_json::Value, icon: &str, color: &str) ->
14681504 } ;
14691505
14701506 // Truncate message
1471- let msg_display = if message. len ( ) > 50 {
1472- format ! ( "{}..." , & message[ ..47 ] )
1473- } else {
1474- message. to_string ( )
1475- } ;
1507+ let msg_display = truncate_safe ( message, 50 ) ;
14761508
14771509 format ! (
14781510 "{}{} L{}:{} {}{}[{}]{} {} {}" ,
@@ -1519,11 +1551,7 @@ fn format_helmlint_result(
15191551 ) ) ;
15201552 for ( i, err) in errors. iter ( ) . take ( 3 ) . enumerate ( ) {
15211553 if let Some ( err_str) = err. as_str ( ) {
1522- let truncated = if err_str. len ( ) > 70 {
1523- format ! ( "{}..." , & err_str[ ..67 ] )
1524- } else {
1525- err_str. to_string ( )
1526- } ;
1554+ let truncated = truncate_safe ( err_str, 70 ) ;
15271555 lines. push ( format ! (
15281556 "{} {} {}{}" ,
15291557 ansi:: HIGH ,
@@ -1655,11 +1683,7 @@ fn format_helmlint_result(
16551683 if let Some ( quick_fixes) = v. get ( "quick_fixes" ) . and_then ( |q| q. as_array ( ) )
16561684 && let Some ( first_fix) = quick_fixes. first ( ) . and_then ( |f| f. as_str ( ) )
16571685 {
1658- let truncated = if first_fix. len ( ) > 70 {
1659- format ! ( "{}..." , & first_fix[ ..67 ] )
1660- } else {
1661- first_fix. to_string ( )
1662- } ;
1686+ let truncated = truncate_safe ( first_fix, 70 ) ;
16631687 lines. push ( format ! (
16641688 "{} → Fix: {}{}" ,
16651689 ansi:: INFO_BLUE ,
@@ -1704,18 +1728,15 @@ fn format_helmlint_issue(issue: &serde_json::Value, icon: &str, color: &str) ->
17041728 } ;
17051729
17061730 // Short file name
1707- let file_short = if file. len ( ) > 20 {
1708- format ! ( "...{}" , & file[ file. len( ) . saturating_sub( 17 ) ..] )
1731+ let file_short = if file. chars ( ) . count ( ) > 20 {
1732+ let skip = file. chars ( ) . count ( ) . saturating_sub ( 17 ) ;
1733+ format ! ( "...{}" , file. chars( ) . skip( skip) . collect:: <String >( ) )
17091734 } else {
17101735 file. to_string ( )
17111736 } ;
17121737
17131738 // Truncate message
1714- let msg_display = if message. len ( ) > 40 {
1715- format ! ( "{}..." , & message[ ..37 ] )
1716- } else {
1717- message. to_string ( )
1718- } ;
1739+ let msg_display = truncate_safe ( message, 40 ) ;
17191740
17201741 format ! (
17211742 "{}{} {}:{}:{} {}{}[{}]{} {} {}" ,
@@ -1923,11 +1944,7 @@ fn format_result_preview(result: &serde_json::Value) -> String {
19231944 . and_then ( |v| v. as_str ( ) )
19241945 . unwrap_or ( "" ) ;
19251946
1926- let detail_short = if detail. len ( ) > 40 {
1927- format ! ( "{}..." , & detail[ ..37 ] )
1928- } else {
1929- detail. to_string ( )
1930- } ;
1947+ let detail_short = truncate_safe ( detail, 40 ) ;
19311948
19321949 if detail_short. is_empty ( ) {
19331950 name. to_string ( )
@@ -1969,8 +1986,10 @@ fn tool_to_focus(tool_name: &str, args: &str) -> Option<String> {
19691986 "read_file" | "write_file" => {
19701987 parsed. get ( "path" ) . and_then ( |p| p. as_str ( ) ) . map ( |p| {
19711988 // Shorten long paths
1972- if p. len ( ) > 50 {
1973- format ! ( "...{}" , & p[ p. len( ) . saturating_sub( 47 ) ..] )
1989+ let char_count = p. chars ( ) . count ( ) ;
1990+ if char_count > 50 {
1991+ let skip = char_count. saturating_sub ( 47 ) ;
1992+ format ! ( "...{}" , p. chars( ) . skip( skip) . collect:: <String >( ) )
19741993 } else {
19751994 p. to_string ( )
19761995 }
@@ -1982,11 +2001,7 @@ fn tool_to_focus(tool_name: &str, args: &str) -> Option<String> {
19822001 . map ( |p| p. to_string ( ) ) ,
19832002 "shell" => parsed. get ( "command" ) . and_then ( |c| c. as_str ( ) ) . map ( |cmd| {
19842003 // Truncate long commands
1985- if cmd. len ( ) > 60 {
1986- format ! ( "{}..." , & cmd[ ..57 ] )
1987- } else {
1988- cmd. to_string ( )
1989- }
2004+ truncate_safe ( cmd, 60 )
19902005 } ) ,
19912006 "hadolint" | "dclint" | "kubelint" | "helmlint" => parsed
19922007 . get ( "path" )
0 commit comments