@@ -291,11 +291,17 @@ pub fn render_transcript_user_message(
291291 width : u16 ,
292292) -> Vec < Line < ' static > > {
293293 // Goal-event messages injected by the /goal machinery render as a compact
294- // event block, not as a user input bubble.
294+ // event block, not as a user input bubble. The same applies to the user's
295+ // own `/goal <objective>` typing — replace it with the yellow GOAL ACTIVE
296+ // badge so the raw slash command doesn't sit next to the `[Goal started]`
297+ // event the machinery injects right after.
295298 if let Some ( ContentBlock :: Text { text } ) = msg. content_blocks ( ) . into_iter ( ) . next ( ) {
296299 if is_goal_event_message ( & text) {
297300 return render_goal_event ( & text, width) ;
298301 }
302+ if let Some ( objective) = extract_goal_slash_objective ( & text) {
303+ return render_goal_active_block ( & objective) ;
304+ }
299305 }
300306
301307 let inner_width = width. saturating_sub ( 4 ) . max ( 10 ) ;
@@ -1615,7 +1621,18 @@ pub fn render_system_api_error(msg: &str, retry_secs: Option<u32>) -> Vec<Line<'
16151621
16161622/// Render a user command invocation (skill invocation display).
16171623/// Shows: `▸ ` in cyan bold + command name in cyan bold + " " + args in white.
1624+ ///
1625+ /// Special case: `/goal <objective>` is replaced with a yellow `GOAL ACTIVE /
1626+ /// Objective: <obj>` badge so the raw slash command doesn't sit next to the
1627+ /// `[Goal started]` event the machinery injects right after it. Subcommands
1628+ /// (`/goal status`, `pause`, `resume`, `clear`, `complete`) keep the normal
1629+ /// rendering.
16181630pub fn render_user_command ( name : & str , args : & str ) -> Vec < Line < ' static > > {
1631+ if name == "goal" {
1632+ if let Some ( objective) = extract_goal_objective_from_args ( args) {
1633+ return render_goal_active_block ( & objective) ;
1634+ }
1635+ }
16191636 vec ! [ Line :: from( vec![
16201637 Span :: styled(
16211638 "\u{25b8} " ,
@@ -1630,6 +1647,92 @@ pub fn render_user_command(name: &str, args: &str) -> Vec<Line<'static>> {
16301647 ] ) ]
16311648}
16321649
1650+ /// Recognizes a raw `/goal <objective>` user message. Returns the objective
1651+ /// string when the first line is `/goal …` with actual objective text;
1652+ /// returns `None` for subcommand forms, no-args, or anything that isn't a
1653+ /// `/goal` slash command (including the case where the user pastes a
1654+ /// multi-line message with `/goal …` somewhere in the middle).
1655+ fn extract_goal_slash_objective ( text : & str ) -> Option < String > {
1656+ let first_line = text. lines ( ) . next ( ) ?;
1657+ let rest = first_line
1658+ . trim_start ( )
1659+ . strip_prefix ( "/goal" ) ?
1660+ . strip_prefix ( |c : char | c. is_whitespace ( ) )
1661+ . unwrap_or ( "" ) ;
1662+ let objective = extract_goal_objective_from_args ( rest) ?;
1663+ // Reject bare `/goal` (no following body) — strip_prefix above returned
1664+ // empty `rest`, which extract_goal_objective_from_args already handles.
1665+ if text. lines ( ) . count ( ) > 1 {
1666+ // If the user typed more than just `/goal …`, fold the rest of the
1667+ // message into the objective so nothing is silently dropped.
1668+ let trailing: String = text. lines ( ) . skip ( 1 ) . collect :: < Vec < _ > > ( ) . join ( "\n " ) ;
1669+ let trailing = trailing. trim ( ) ;
1670+ if !trailing. is_empty ( ) {
1671+ return Some ( format ! ( "{}\n {}" , objective, trailing) ) ;
1672+ }
1673+ }
1674+ Some ( objective)
1675+ }
1676+
1677+ /// Pulls the objective text out of the `args` portion of a `/goal …` slash
1678+ /// command. Returns `None` for empty args or for the subcommand forms
1679+ /// (`status`, `pause`, `resume`, `clear`, `complete`).
1680+ fn extract_goal_objective_from_args ( args : & str ) -> Option < String > {
1681+ let trimmed = args. trim ( ) ;
1682+ if trimmed. is_empty ( ) {
1683+ return None ;
1684+ }
1685+ // Strip an optional `--tokens <budget>` prefix so the objective shown
1686+ // doesn't include the budget flag.
1687+ let rest = if let Some ( after_flag) = trimmed. strip_prefix ( "--tokens" ) {
1688+ let after_flag = after_flag. trim_start ( ) ;
1689+ after_flag
1690+ . splitn ( 2 , char:: is_whitespace)
1691+ . nth ( 1 )
1692+ . unwrap_or ( "" )
1693+ . trim ( )
1694+ } else {
1695+ trimmed
1696+ } ;
1697+ if rest. is_empty ( ) {
1698+ return None ;
1699+ }
1700+ let first = rest
1701+ . split_whitespace ( )
1702+ . next ( )
1703+ . unwrap_or ( "" )
1704+ . to_ascii_lowercase ( ) ;
1705+ if matches ! (
1706+ first. as_str( ) ,
1707+ "status" | "pause" | "resume" | "clear" | "complete"
1708+ ) {
1709+ return None ;
1710+ }
1711+ Some ( rest. to_string ( ) )
1712+ }
1713+
1714+ /// Render the yellow `GOAL ACTIVE / Objective: …` badge that replaces the
1715+ /// `/goal <objective>` user-input line in the transcript.
1716+ fn render_goal_active_block ( objective : & str ) -> Vec < Line < ' static > > {
1717+ vec ! [
1718+ Line :: from( vec![ Span :: styled(
1719+ " GOAL ACTIVE" . to_string( ) ,
1720+ Style :: default ( )
1721+ . fg( GOAL_ACCENT )
1722+ . add_modifier( Modifier :: BOLD ) ,
1723+ ) ] ) ,
1724+ Line :: from( vec![
1725+ Span :: styled(
1726+ " Objective: " . to_string( ) ,
1727+ Style :: default ( )
1728+ . fg( GOAL_ACCENT )
1729+ . add_modifier( Modifier :: BOLD ) ,
1730+ ) ,
1731+ Span :: styled( objective. to_string( ) , Style :: default ( ) . fg( GOAL_BODY ) ) ,
1732+ ] ) ,
1733+ ]
1734+ }
1735+
16331736/// Render a user memory input line.
16341737/// Shows: `# {key}: {value}` in cyan, with an optional ` Got it.` line in dark gray italic.
16351738pub fn render_user_memory_input ( key : & str , value : & str ) -> Vec < Line < ' static > > {
@@ -1798,18 +1901,6 @@ pub fn is_goal_event_message(text: &str) -> bool {
17981901 || text. starts_with ( "[Goal continuation -" ) // fallback
17991902}
18001903
1801- /// Extract the objective text between `<objective>` and `</objective>` tags.
1802- fn extract_goal_objective ( text : & str ) -> Option < String > {
1803- let tag = "<objective>" ;
1804- let start = text. find ( tag) ? + tag. len ( ) ;
1805- let end = text. find ( "</objective>" ) ?;
1806- if end > start {
1807- Some ( text[ start..end] . trim ( ) . to_string ( ) )
1808- } else {
1809- None
1810- }
1811- }
1812-
18131904/// Extract the turn number from a "[Goal continuation — turn N]" header.
18141905fn extract_goal_turn ( text : & str ) -> Option < u32 > {
18151906 // Find the first [...] bracket, search inside for "turn <N>"
@@ -1824,9 +1915,12 @@ fn extract_goal_turn(text: &str) -> Option<u32> {
18241915
18251916/// Render a goal-event message block.
18261917///
1827- /// `[Goal started]` shows the ◎ badge and the objective text.
1918+ /// `[Goal started]` renders as nothing — the user's typed `/goal …` line
1919+ /// already produces the canonical GOAL ACTIVE block via
1920+ /// `render_goal_active_block`, so showing the kickoff event too would
1921+ /// duplicate it.
18281922/// `[Goal continuation — turn N]` shows a compact inline turn marker.
1829- pub fn render_goal_event ( text : & str , width : u16 ) -> Vec < Line < ' static > > {
1923+ pub fn render_goal_event ( text : & str , _width : u16 ) -> Vec < Line < ' static > > {
18301924 if text. starts_with ( "[Goal continuation —" ) {
18311925 let turn = extract_goal_turn ( text) . unwrap_or ( 0 ) ;
18321926 return vec ! [ Line :: from( vec![
@@ -1841,93 +1935,8 @@ pub fn render_goal_event(text: &str, width: u16) -> Vec<Line<'static>> {
18411935 ] ) ] ;
18421936 }
18431937
1844- // [Goal started] — header + wrapped objective
1845- let mut lines = Vec :: new ( ) ;
1846- lines. push ( Line :: from ( vec ! [
1847- Span :: styled(
1848- " \u{25ce} " . to_string( ) , // ◎
1849- Style :: default ( ) . fg( GOAL_ACCENT ) . add_modifier( Modifier :: BOLD ) ,
1850- ) ,
1851- Span :: styled(
1852- "goal started" . to_string( ) ,
1853- Style :: default ( ) . fg( GOAL_ACCENT ) . add_modifier( Modifier :: BOLD ) ,
1854- ) ,
1855- ] ) ) ;
1856-
1857- let objective = extract_goal_objective ( text) . unwrap_or_default ( ) ;
1858- if !objective. is_empty ( ) {
1859- let usable = ( width as usize ) . saturating_sub ( 6 ) . max ( 20 ) ;
1860- for line in wrap_plain_text ( & objective, usable) {
1861- lines. push ( Line :: from ( vec ! [
1862- Span :: styled( " " . to_string( ) , Style :: default ( ) ) ,
1863- Span :: styled( line, Style :: default ( ) . fg( GOAL_BODY ) ) ,
1864- ] ) ) ;
1865- }
1866- }
1867-
1868- lines
1869- }
1870-
1871- /// Simple plain-text word-wrap (no markdown, no indent prefix).
1872- ///
1873- /// Words longer than `max_width` (e.g. URLs) are hard-broken at character
1874- /// boundaries so they do not overflow the buffer (issue #149 follow-up:
1875- /// long URLs at end of message went off-screen).
1876- fn wrap_plain_text ( text : & str , max_width : usize ) -> Vec < String > {
1877- if max_width == 0 {
1878- return vec ! [ text. to_string( ) ] ;
1879- }
1880- let mut out = Vec :: new ( ) ;
1881- for para in text. lines ( ) {
1882- if para. is_empty ( ) {
1883- out. push ( String :: new ( ) ) ;
1884- continue ;
1885- }
1886- let mut current = String :: new ( ) ;
1887- let mut current_len = 0usize ;
1888- for word in para. split_whitespace ( ) {
1889- let word_len = word. chars ( ) . count ( ) ;
1890- if word_len > max_width {
1891- // Flush whatever we have, then hard-break the long word.
1892- if !current. is_empty ( ) {
1893- out. push ( std:: mem:: take ( & mut current) ) ;
1894- current_len = 0 ;
1895- }
1896- let chars: Vec < char > = word. chars ( ) . collect ( ) ;
1897- let mut i = 0 ;
1898- while i < chars. len ( ) {
1899- let end = ( i + max_width) . min ( chars. len ( ) ) ;
1900- let chunk: String = chars[ i..end] . iter ( ) . collect ( ) ;
1901- if end == chars. len ( ) {
1902- // Last fragment becomes the start of the next visual
1903- // line so following words can flow after it.
1904- current = chunk;
1905- current_len = end - i;
1906- } else {
1907- out. push ( chunk) ;
1908- }
1909- i = end;
1910- }
1911- continue ;
1912- }
1913- if current. is_empty ( ) {
1914- current. push_str ( word) ;
1915- current_len = word_len;
1916- } else if current_len + 1 + word_len <= max_width {
1917- current. push ( ' ' ) ;
1918- current. push_str ( word) ;
1919- current_len += 1 + word_len;
1920- } else {
1921- out. push ( std:: mem:: take ( & mut current) ) ;
1922- current. push_str ( word) ;
1923- current_len = word_len;
1924- }
1925- }
1926- if !current. is_empty ( ) {
1927- out. push ( current) ;
1928- }
1929- }
1930- out
1938+ // [Goal started] — hidden.
1939+ Vec :: new ( )
19311940}
19321941
19331942#[ cfg( test) ]
@@ -2276,6 +2285,75 @@ mod tests {
22762285 assert ! ( text. contains( "--verbose" ) ) ;
22772286 }
22782287
2288+ #[ test]
2289+ fn goal_objective_renders_goal_active_block_not_user_command ( ) {
2290+ let result = render_user_command ( "goal" , "Migrate to React" ) ;
2291+ let header = line_text ( & result[ 0 ] ) ;
2292+ let body = line_text ( & result[ 1 ] ) ;
2293+ assert ! ( header. contains( "GOAL ACTIVE" ) ) ;
2294+ assert ! ( !header. contains( '\u{25b8}' ) , "should not show ▸ user-command prefix" ) ;
2295+ assert ! ( body. contains( "Objective:" ) ) ;
2296+ assert ! ( body. contains( "Migrate to React" ) ) ;
2297+ }
2298+
2299+ #[ test]
2300+ fn goal_subcommands_render_as_normal_user_command ( ) {
2301+ for sub in [ "status" , "pause" , "resume" , "clear" , "complete" ] {
2302+ let result = render_user_command ( "goal" , sub) ;
2303+ let text = line_text ( & result[ 0 ] ) ;
2304+ assert ! ( text. contains( '\u{25b8}' ) , "/goal {sub} should keep ▸ prefix" ) ;
2305+ assert ! ( text. contains( sub) ) ;
2306+ }
2307+ }
2308+
2309+ #[ test]
2310+ fn goal_with_tokens_flag_strips_flag_from_objective ( ) {
2311+ let result = render_user_command ( "goal" , "--tokens 250K Migrate to React" ) ;
2312+ let body = line_text ( & result[ 1 ] ) ;
2313+ assert ! ( body. contains( "Migrate to React" ) ) ;
2314+ assert ! ( !body. contains( "--tokens" ) , "flag should not appear in displayed objective" ) ;
2315+ assert ! ( !body. contains( "250K" ) ) ;
2316+ }
2317+
2318+ #[ test]
2319+ fn extract_goal_objective_returns_none_for_subcommands_and_empty ( ) {
2320+ assert ! ( extract_goal_objective_from_args( "" ) . is_none( ) ) ;
2321+ assert ! ( extract_goal_objective_from_args( " " ) . is_none( ) ) ;
2322+ assert ! ( extract_goal_objective_from_args( "status" ) . is_none( ) ) ;
2323+ assert ! ( extract_goal_objective_from_args( "pause now" ) . is_none( ) ) ; // first token is subcommand
2324+ assert_eq ! (
2325+ extract_goal_objective_from_args( "Migrate to React" ) . as_deref( ) ,
2326+ Some ( "Migrate to React" ) ,
2327+ ) ;
2328+ }
2329+
2330+ #[ test]
2331+ fn extract_goal_slash_objective_handles_typed_user_message ( ) {
2332+ assert_eq ! (
2333+ extract_goal_slash_objective( "/goal build GPT 6 make no mistakes" ) . as_deref( ) ,
2334+ Some ( "build GPT 6 make no mistakes" ) ,
2335+ ) ;
2336+ assert_eq ! (
2337+ extract_goal_slash_objective( "/goal --tokens 250K Migrate to React" ) . as_deref( ) ,
2338+ Some ( "Migrate to React" ) ,
2339+ ) ;
2340+ // Subcommands fall through.
2341+ assert ! ( extract_goal_slash_objective( "/goal status" ) . is_none( ) ) ;
2342+ assert ! ( extract_goal_slash_objective( "/goal" ) . is_none( ) ) ;
2343+ // Not a /goal message.
2344+ assert ! ( extract_goal_slash_objective( "just a normal message" ) . is_none( ) ) ;
2345+ assert ! ( extract_goal_slash_objective( "/goalbuild" ) . is_none( ) ) ;
2346+ }
2347+
2348+ #[ test]
2349+ fn extract_goal_slash_objective_folds_trailing_lines_into_objective ( ) {
2350+ let text = "/goal Migrate to React\n with strict typing\n and tests passing" ;
2351+ let extracted = extract_goal_slash_objective ( text) . unwrap ( ) ;
2352+ assert ! ( extracted. starts_with( "Migrate to React" ) ) ;
2353+ assert ! ( extracted. contains( "strict typing" ) ) ;
2354+ assert ! ( extracted. contains( "tests passing" ) ) ;
2355+ }
2356+
22792357 #[ test]
22802358 fn test_render_user_memory_input ( ) {
22812359 let result = render_user_memory_input ( "project" , "Claurst" ) ;
0 commit comments