Skip to content

Commit 964f98e

Browse files
committed
feat(tui): /goal UI revamp
1 parent 8489639 commit 964f98e

1 file changed

Lines changed: 180 additions & 102 deletions

File tree

  • src-rust/crates/tui/src/messages

src-rust/crates/tui/src/messages/mod.rs

Lines changed: 180 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
16181630
pub 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.
16351738
pub 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.
18141905
fn 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\nwith strict typing\nand 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

Comments
 (0)