Skip to content

Commit f27de81

Browse files
authored
feat: Add /tangent tail to preserve the last tangent conversation (#2838)
Users can now keep the final question and answer from tangent mode by using `/tangent tail` instead of `/tangent`. This preserves the last Q&A pair when returning to the main conversation, making it easy to retain helpful insights discovered during exploration. - `/tangent` - exits tangent mode (existing behavior unchanged) - `/tangent tail` - exits tangent mode but keeps the last Q&A pair This enables users to safely explore topics without losing the final valuable insight that could benefit their main conversation flow.
1 parent 39a0964 commit f27de81

File tree

3 files changed

+254
-63
lines changed

3 files changed

+254
-63
lines changed

crates/chat-cli/src/cli/chat/cli/tangent.rs

Lines changed: 107 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use clap::Args;
1+
use clap::{
2+
Args,
3+
Subcommand,
4+
};
25
use crossterm::execute;
36
use crossterm::style::{
47
self,
@@ -14,9 +17,33 @@ use crate::database::settings::Setting;
1417
use crate::os::Os;
1518

1619
#[derive(Debug, PartialEq, Args)]
17-
pub struct TangentArgs;
20+
pub struct TangentArgs {
21+
#[command(subcommand)]
22+
pub subcommand: Option<TangentSubcommand>,
23+
}
24+
25+
#[derive(Debug, PartialEq, Subcommand)]
26+
pub enum TangentSubcommand {
27+
/// Exit tangent mode and keep the last conversation entry (user question + assistant response)
28+
Tail,
29+
}
1830

1931
impl TangentArgs {
32+
async fn send_tangent_telemetry(os: &Os, session: &ChatSession, duration_seconds: i64) {
33+
if let Err(err) = os
34+
.telemetry
35+
.send_tangent_mode_session(
36+
&os.database,
37+
session.conversation.conversation_id().to_string(),
38+
crate::telemetry::TelemetryResult::Succeeded,
39+
crate::telemetry::core::TangentModeSessionArgs { duration_seconds },
40+
)
41+
.await
42+
{
43+
tracing::warn!(?err, "Failed to send tangent mode session telemetry");
44+
}
45+
}
46+
2047
pub async fn execute(self, os: &Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
2148
// Check if tangent mode is enabled
2249
if !os
@@ -35,69 +62,86 @@ impl TangentArgs {
3562
skip_printing_tools: true,
3663
});
3764
}
38-
if session.conversation.is_in_tangent_mode() {
39-
// Get duration before exiting tangent mode
40-
let duration_seconds = session.conversation.get_tangent_duration_seconds().unwrap_or(0);
41-
42-
session.conversation.exit_tangent_mode();
43-
44-
// Send telemetry for tangent mode session
45-
if let Err(err) = os
46-
.telemetry
47-
.send_tangent_mode_session(
48-
&os.database,
49-
session.conversation.conversation_id().to_string(),
50-
crate::telemetry::TelemetryResult::Succeeded,
51-
crate::telemetry::core::TangentModeSessionArgs { duration_seconds },
52-
)
53-
.await
54-
{
55-
tracing::warn!(?err, "Failed to send tangent mode session telemetry");
56-
}
5765

58-
execute!(
59-
session.stderr,
60-
style::SetForegroundColor(Color::DarkGrey),
61-
style::Print("Restored conversation from checkpoint ("),
62-
style::SetForegroundColor(Color::Yellow),
63-
style::Print("↯"),
64-
style::SetForegroundColor(Color::DarkGrey),
65-
style::Print("). - Returned to main conversation.\n"),
66-
style::SetForegroundColor(Color::Reset)
67-
)?;
68-
} else {
69-
session.conversation.enter_tangent_mode();
70-
71-
// Get the configured tangent mode key for display
72-
let tangent_key_char = match os
73-
.database
74-
.settings
75-
.get_string(crate::database::settings::Setting::TangentModeKey)
76-
{
77-
Some(key) if key.len() == 1 => key.chars().next().unwrap_or('t'),
78-
_ => 't', // Default to 't' if setting is missing or invalid
79-
};
80-
let tangent_key_display = format!("ctrl + {}", tangent_key_char.to_lowercase());
66+
match self.subcommand {
67+
Some(TangentSubcommand::Tail) => {
68+
if session.conversation.is_in_tangent_mode() {
69+
let duration_seconds = session.conversation.get_tangent_duration_seconds().unwrap_or(0);
70+
session.conversation.exit_tangent_mode_with_tail();
71+
Self::send_tangent_telemetry(os, session, duration_seconds).await;
8172

82-
execute!(
83-
session.stderr,
84-
style::SetForegroundColor(Color::DarkGrey),
85-
style::Print("Created a conversation checkpoint ("),
86-
style::SetForegroundColor(Color::Yellow),
87-
style::Print("↯"),
88-
style::SetForegroundColor(Color::DarkGrey),
89-
style::Print("). Use "),
90-
style::SetForegroundColor(Color::Green),
91-
style::Print(&tangent_key_display),
92-
style::SetForegroundColor(Color::DarkGrey),
93-
style::Print(" or "),
94-
style::SetForegroundColor(Color::Green),
95-
style::Print("/tangent"),
96-
style::SetForegroundColor(Color::DarkGrey),
97-
style::Print(" to restore the conversation later.\n"),
98-
style::Print("Note: this functionality is experimental and may change or be removed in the future.\n"),
99-
style::SetForegroundColor(Color::Reset)
100-
)?;
73+
execute!(
74+
session.stderr,
75+
style::SetForegroundColor(Color::DarkGrey),
76+
style::Print("Restored conversation from checkpoint ("),
77+
style::SetForegroundColor(Color::Yellow),
78+
style::Print("↯"),
79+
style::SetForegroundColor(Color::DarkGrey),
80+
style::Print(") with last conversation entry preserved.\n"),
81+
style::SetForegroundColor(Color::Reset)
82+
)?;
83+
} else {
84+
execute!(
85+
session.stderr,
86+
style::SetForegroundColor(Color::Red),
87+
style::Print("You need to be in tangent mode to use tail.\n"),
88+
style::SetForegroundColor(Color::Reset)
89+
)?;
90+
}
91+
},
92+
None => {
93+
if session.conversation.is_in_tangent_mode() {
94+
let duration_seconds = session.conversation.get_tangent_duration_seconds().unwrap_or(0);
95+
session.conversation.exit_tangent_mode();
96+
Self::send_tangent_telemetry(os, session, duration_seconds).await;
97+
98+
execute!(
99+
session.stderr,
100+
style::SetForegroundColor(Color::DarkGrey),
101+
style::Print("Restored conversation from checkpoint ("),
102+
style::SetForegroundColor(Color::Yellow),
103+
style::Print("↯"),
104+
style::SetForegroundColor(Color::DarkGrey),
105+
style::Print("). - Returned to main conversation.\n"),
106+
style::SetForegroundColor(Color::Reset)
107+
)?;
108+
} else {
109+
session.conversation.enter_tangent_mode();
110+
111+
// Get the configured tangent mode key for display
112+
let tangent_key_char = match os
113+
.database
114+
.settings
115+
.get_string(crate::database::settings::Setting::TangentModeKey)
116+
{
117+
Some(key) if key.len() == 1 => key.chars().next().unwrap_or('t'),
118+
_ => 't', // Default to 't' if setting is missing or invalid
119+
};
120+
let tangent_key_display = format!("ctrl + {}", tangent_key_char.to_lowercase());
121+
122+
execute!(
123+
session.stderr,
124+
style::SetForegroundColor(Color::DarkGrey),
125+
style::Print("Created a conversation checkpoint ("),
126+
style::SetForegroundColor(Color::Yellow),
127+
style::Print("↯"),
128+
style::SetForegroundColor(Color::DarkGrey),
129+
style::Print("). Use "),
130+
style::SetForegroundColor(Color::Green),
131+
style::Print(&tangent_key_display),
132+
style::SetForegroundColor(Color::DarkGrey),
133+
style::Print(" or "),
134+
style::SetForegroundColor(Color::Green),
135+
style::Print("/tangent"),
136+
style::SetForegroundColor(Color::DarkGrey),
137+
style::Print(" to restore the conversation later.\n"),
138+
style::Print(
139+
"Note: this functionality is experimental and may change or be removed in the future.\n"
140+
),
141+
style::SetForegroundColor(Color::Reset)
142+
)?;
143+
}
144+
},
101145
}
102146

103147
Ok(ChatState::PromptUser {

crates/chat-cli/src/cli/chat/conversation.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,27 @@ impl ConversationState {
269269
}
270270
}
271271

272+
/// Exit tangent mode and preserve the last conversation entry (user + assistant)
273+
pub fn exit_tangent_mode_with_tail(&mut self) {
274+
if let Some(checkpoint) = self.tangent_state.take() {
275+
// Capture the last history entry from tangent conversation if it exists
276+
// and if it's different from what was in the main conversation
277+
let last_entry = if self.history.len() > checkpoint.main_history.len() {
278+
self.history.back().cloned()
279+
} else {
280+
None // No new entries in tangent mode
281+
};
282+
283+
// Restore from checkpoint
284+
self.restore_from_checkpoint(checkpoint);
285+
286+
// Add the last entry if it exists
287+
if let Some(entry) = last_entry {
288+
self.history.push_back(entry);
289+
}
290+
}
291+
}
292+
272293
/// Appends a collection prompts into history and returns the last message in the collection.
273294
/// It asserts that the collection ends with a prompt that assumes the role of user.
274295
pub fn append_prompts(&mut self, mut prompts: VecDeque<PromptMessage>) -> Option<String> {
@@ -1568,4 +1589,99 @@ mod tests {
15681589
// No duration when not in tangent mode
15691590
assert!(conversation.get_tangent_duration_seconds().is_none());
15701591
}
1592+
1593+
#[tokio::test]
1594+
async fn test_tangent_mode_with_tail() {
1595+
let mut os = Os::new().await.unwrap();
1596+
let agents = Agents::default();
1597+
let mut tool_manager = ToolManager::default();
1598+
let mut conversation = ConversationState::new(
1599+
"test_conv_id",
1600+
agents,
1601+
tool_manager.load_tools(&mut os, &mut vec![]).await.unwrap(),
1602+
tool_manager,
1603+
None,
1604+
&os,
1605+
false,
1606+
)
1607+
.await;
1608+
1609+
// Add main conversation
1610+
conversation.set_next_user_message("main question".to_string()).await;
1611+
conversation.push_assistant_message(
1612+
&mut os,
1613+
AssistantMessage::new_response(None, "main response".to_string()),
1614+
None,
1615+
);
1616+
1617+
let main_history_len = conversation.history.len();
1618+
1619+
// Enter tangent mode
1620+
conversation.enter_tangent_mode();
1621+
assert!(conversation.is_in_tangent_mode());
1622+
1623+
// Add tangent conversation
1624+
conversation.set_next_user_message("tangent question".to_string()).await;
1625+
conversation.push_assistant_message(
1626+
&mut os,
1627+
AssistantMessage::new_response(None, "tangent response".to_string()),
1628+
None,
1629+
);
1630+
1631+
// Exit tangent mode with tail
1632+
conversation.exit_tangent_mode_with_tail();
1633+
assert!(!conversation.is_in_tangent_mode());
1634+
1635+
// Should have main conversation + last assistant message from tangent
1636+
assert_eq!(conversation.history.len(), main_history_len + 1);
1637+
1638+
// Check that the last message is the tangent response
1639+
if let Some(entry) = conversation.history.back() {
1640+
assert_eq!(entry.assistant.content(), "tangent response");
1641+
} else {
1642+
panic!("Expected history entry at the end");
1643+
}
1644+
}
1645+
1646+
#[tokio::test]
1647+
async fn test_tangent_mode_with_tail_edge_cases() {
1648+
let mut os = Os::new().await.unwrap();
1649+
let agents = Agents::default();
1650+
let mut tool_manager = ToolManager::default();
1651+
let mut conversation = ConversationState::new(
1652+
"test_conv_id",
1653+
agents,
1654+
tool_manager.load_tools(&mut os, &mut vec![]).await.unwrap(),
1655+
tool_manager,
1656+
None,
1657+
&os,
1658+
false,
1659+
)
1660+
.await;
1661+
1662+
// Add main conversation
1663+
conversation.set_next_user_message("main question".to_string()).await;
1664+
conversation.push_assistant_message(
1665+
&mut os,
1666+
AssistantMessage::new_response(None, "main response".to_string()),
1667+
None,
1668+
);
1669+
1670+
let main_history_len = conversation.history.len();
1671+
1672+
// Test: Enter tangent mode but don't add any new conversation
1673+
conversation.enter_tangent_mode();
1674+
assert!(conversation.is_in_tangent_mode());
1675+
1676+
// Exit tangent mode with tail (should not add anything since no new entries)
1677+
conversation.exit_tangent_mode_with_tail();
1678+
assert!(!conversation.is_in_tangent_mode());
1679+
1680+
// Should have same length as before (no new entries added)
1681+
assert_eq!(conversation.history.len(), main_history_len);
1682+
1683+
// Test: Call exit_tangent_mode_with_tail when not in tangent mode (should do nothing)
1684+
conversation.exit_tangent_mode_with_tail();
1685+
assert_eq!(conversation.history.len(), main_history_len);
1686+
}
15711687
}

docs/tangent-mode.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ Use `/tangent` or Ctrl+T again:
3232
Restored conversation from checkpoint (↯). - Returned to main conversation.
3333
```
3434

35+
### Exit Tangent Mode with Tail
36+
Use `/tangent tail` to preserve the last conversation entry (question + answer):
37+
```
38+
↯ > /tangent tail
39+
Restored conversation from checkpoint (↯) with last conversation entry preserved.
40+
```
41+
3542
## Usage Examples
3643

3744
### Example 1: Exploring Alternatives
@@ -93,6 +100,29 @@ Restored conversation from checkpoint (↯).
93100
> Here's my query: SELECT * FROM orders...
94101
```
95102

103+
### Example 4: Keeping Useful Information
104+
```
105+
> Help me debug this Python error
106+
107+
I can help you debug that. Could you share the error message?
108+
109+
> /tangent
110+
Created a conversation checkpoint (↯).
111+
112+
↯ > What are the most common Python debugging techniques?
113+
114+
Here are the most effective Python debugging techniques:
115+
1. Use print statements strategically
116+
2. Leverage the Python debugger (pdb)...
117+
118+
↯ > /tangent tail
119+
Restored conversation from checkpoint (↯) with last conversation entry preserved.
120+
121+
> Here's my error: TypeError: unsupported operand type(s)...
122+
123+
# The preserved entry (question + answer about debugging techniques) is now part of main conversation
124+
```
125+
96126
## Configuration
97127

98128
### Keyboard Shortcut
@@ -131,6 +161,7 @@ q settings introspect.tangentMode true
131161
2. **Return promptly** - Don't forget you're in tangent mode
132162
3. **Use for clarification** - Perfect for "wait, what does X mean?" questions
133163
4. **Experiment safely** - Test ideas without affecting main conversation
164+
5. **Use `/tangent tail`** - When both the tangent question and answer are useful for main conversation
134165

135166
## Limitations
136167

0 commit comments

Comments
 (0)