Skip to content

Commit 967ce49

Browse files
authored
feat: add tangent mode for context isolated conversations (#2634)
- Add /tangent command to toggle between normal and tangent modes - Preserve conversation history during tangent for context - Restore main conversation when exiting tangent mode - Add Ctrl+T keyboard shortcut for quick access - Visual indicator: green [T] prompt in tangent mode - Comprehensive tests for state management and UI components Allows users to explore side topics without polluting main conversation context.
1 parent 209dfbf commit 967ce49

File tree

8 files changed

+402
-196
lines changed

8 files changed

+402
-196
lines changed

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

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod persist;
1010
pub mod profile;
1111
pub mod prompts;
1212
pub mod subscribe;
13+
pub mod tangent;
1314
pub mod tools;
1415
pub mod usage;
1516

@@ -25,17 +26,13 @@ use model::ModelArgs;
2526
use persist::PersistSubcommand;
2627
use profile::AgentSubcommand;
2728
use prompts::PromptsArgs;
29+
use tangent::TangentArgs;
2830
use tools::ToolsArgs;
2931

3032
use crate::cli::chat::cli::subscribe::SubscribeArgs;
3133
use crate::cli::chat::cli::usage::UsageArgs;
3234
use crate::cli::chat::consts::AGENT_MIGRATION_DOC_URL;
33-
use crate::cli::chat::{
34-
ChatError,
35-
ChatSession,
36-
ChatState,
37-
EXTRA_HELP,
38-
};
35+
use crate::cli::chat::{ChatError, ChatSession, ChatState, EXTRA_HELP};
3936
use crate::cli::issue;
4037
use crate::os::Os;
4138

@@ -81,6 +78,8 @@ pub enum SlashCommand {
8178
Model(ModelArgs),
8279
/// Upgrade to a Q Developer Pro subscription for increased query limits
8380
Subscribe(SubscribeArgs),
81+
/// Toggle tangent mode for isolated conversations
82+
Tangent(TangentArgs),
8483
#[command(flatten)]
8584
Persist(PersistSubcommand),
8685
// #[command(flatten)]
@@ -94,10 +93,7 @@ impl SlashCommand {
9493
Self::Clear(args) => args.execute(session).await,
9594
Self::Agent(subcommand) => subcommand.execute(os, session).await,
9695
Self::Profile => {
97-
use crossterm::{
98-
execute,
99-
style,
100-
};
96+
use crossterm::{execute, style};
10197
execute!(
10298
session.stderr,
10399
style::SetForegroundColor(style::Color::Yellow),
@@ -136,6 +132,7 @@ impl SlashCommand {
136132
Self::Mcp(args) => args.execute(session).await,
137133
Self::Model(args) => args.execute(os, session).await,
138134
Self::Subscribe(args) => args.execute(os, session).await,
135+
Self::Tangent(args) => args.execute(os, session).await,
139136
Self::Persist(subcommand) => subcommand.execute(os, session).await,
140137
// Self::Root(subcommand) => {
141138
// if let Err(err) = subcommand.execute(os, database, telemetry).await {
@@ -167,6 +164,7 @@ impl SlashCommand {
167164
Self::Mcp(_) => "mcp",
168165
Self::Model(_) => "model",
169166
Self::Subscribe(_) => "subscribe",
167+
Self::Tangent(_) => "tangent",
170168
Self::Persist(sub) => match sub {
171169
PersistSubcommand::Save { .. } => "save",
172170
PersistSubcommand::Load { .. } => "load",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
use clap::Args;
2+
use crossterm::execute;
3+
use crossterm::style::{self, Color};
4+
5+
use crate::cli::chat::{ChatError, ChatSession, ChatState};
6+
use crate::os::Os;
7+
8+
#[derive(Debug, PartialEq, Args)]
9+
pub struct TangentArgs;
10+
11+
impl TangentArgs {
12+
pub async fn execute(self, os: &Os, session: &mut ChatSession) -> Result<ChatState, ChatError> {
13+
if session.conversation.is_in_tangent_mode() {
14+
session.conversation.exit_tangent_mode();
15+
execute!(
16+
session.stderr,
17+
style::SetForegroundColor(Color::DarkGrey),
18+
style::Print("Restored conversation from checkpoint ("),
19+
style::SetForegroundColor(Color::Yellow),
20+
style::Print("↯"),
21+
style::SetForegroundColor(Color::DarkGrey),
22+
style::Print("). - Returned to main conversation.\n"),
23+
style::SetForegroundColor(Color::Reset)
24+
)?;
25+
} else {
26+
session.conversation.enter_tangent_mode();
27+
28+
// Get the configured tangent mode key for display
29+
let tangent_key_char = match os
30+
.database
31+
.settings
32+
.get_string(crate::database::settings::Setting::TangentModeKey)
33+
{
34+
Some(key) if key.len() == 1 => key.chars().next().unwrap_or('t'),
35+
_ => 't', // Default to 't' if setting is missing or invalid
36+
};
37+
let tangent_key_display = format!("ctrl + {}", tangent_key_char.to_lowercase());
38+
39+
execute!(
40+
session.stderr,
41+
style::SetForegroundColor(Color::DarkGrey),
42+
style::Print("Created a conversation checkpoint ("),
43+
style::SetForegroundColor(Color::Yellow),
44+
style::Print("↯"),
45+
style::SetForegroundColor(Color::DarkGrey),
46+
style::Print("). Use "),
47+
style::SetForegroundColor(Color::Green),
48+
style::Print(&tangent_key_display),
49+
style::SetForegroundColor(Color::DarkGrey),
50+
style::Print(" or "),
51+
style::SetForegroundColor(Color::Green),
52+
style::Print("/tangent"),
53+
style::SetForegroundColor(Color::DarkGrey),
54+
style::Print(" to restore the conversation later.\n"),
55+
style::Print(
56+
"Note: this functionality is experimental and may change or be removed in the future.\n"
57+
),
58+
style::SetForegroundColor(Color::Reset)
59+
)?;
60+
}
61+
62+
Ok(ChatState::PromptUser {
63+
skip_printing_tools: false,
64+
})
65+
}
66+
}

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,19 @@ pub struct ConversationState {
126126
pub file_line_tracker: HashMap<String, FileLineTracker>,
127127
#[serde(default = "default_true")]
128128
pub mcp_enabled: bool,
129+
/// Tangent mode checkpoint - stores main conversation when in tangent mode
130+
#[serde(default, skip_serializing_if = "Option::is_none")]
131+
tangent_state: Option<ConversationCheckpoint>,
132+
}
133+
134+
#[derive(Debug, Clone, Serialize, Deserialize)]
135+
struct ConversationCheckpoint {
136+
/// Main conversation history stored while in tangent mode
137+
main_history: VecDeque<HistoryEntry>,
138+
/// Main conversation next message
139+
main_next_message: Option<UserMessage>,
140+
/// Main conversation transcript
141+
main_transcript: VecDeque<String>,
129142
}
130143

131144
impl ConversationState {
@@ -184,6 +197,7 @@ impl ConversationState {
184197
model_info: model,
185198
file_line_tracker: HashMap::new(),
186199
mcp_enabled,
200+
tangent_state: None,
187201
}
188202
}
189203

@@ -204,6 +218,42 @@ impl ConversationState {
204218
}
205219
}
206220

221+
/// Check if currently in tangent mode
222+
pub fn is_in_tangent_mode(&self) -> bool {
223+
self.tangent_state.is_some()
224+
}
225+
226+
/// Create a checkpoint of current conversation state
227+
fn create_checkpoint(&self) -> ConversationCheckpoint {
228+
ConversationCheckpoint {
229+
main_history: self.history.clone(),
230+
main_next_message: self.next_message.clone(),
231+
main_transcript: self.transcript.clone(),
232+
}
233+
}
234+
235+
/// Restore conversation state from checkpoint
236+
fn restore_from_checkpoint(&mut self, checkpoint: ConversationCheckpoint) {
237+
self.history = checkpoint.main_history;
238+
self.next_message = checkpoint.main_next_message;
239+
self.transcript = checkpoint.main_transcript;
240+
self.valid_history_range = (0, self.history.len());
241+
}
242+
243+
/// Enter tangent mode - creates checkpoint of current state
244+
pub fn enter_tangent_mode(&mut self) {
245+
if self.tangent_state.is_none() {
246+
self.tangent_state = Some(self.create_checkpoint());
247+
}
248+
}
249+
250+
/// Exit tangent mode - restore from checkpoint
251+
pub fn exit_tangent_mode(&mut self) {
252+
if let Some(checkpoint) = self.tangent_state.take() {
253+
self.restore_from_checkpoint(checkpoint);
254+
}
255+
}
256+
207257
/// Appends a collection prompts into history and returns the last message in the collection.
208258
/// It asserts that the collection ends with a prompt that assumes the role of user.
209259
pub fn append_prompts(&mut self, mut prompts: VecDeque<Prompt>) -> Option<String> {
@@ -1289,4 +1339,65 @@ mod tests {
12891339
conversation.set_next_user_message(i.to_string()).await;
12901340
}
12911341
}
1342+
1343+
#[tokio::test]
1344+
async fn test_tangent_mode() {
1345+
let mut os = Os::new().await.unwrap();
1346+
let agents = Agents::default();
1347+
let mut tool_manager = ToolManager::default();
1348+
let mut conversation = ConversationState::new(
1349+
"fake_conv_id",
1350+
agents,
1351+
tool_manager.load_tools(&mut os, &mut vec![]).await.unwrap(),
1352+
tool_manager,
1353+
None,
1354+
&os,
1355+
false, // mcp_enabled
1356+
)
1357+
.await;
1358+
1359+
// Initially not in tangent mode
1360+
assert!(!conversation.is_in_tangent_mode());
1361+
1362+
// Add some main conversation history
1363+
conversation.set_next_user_message("main conversation".to_string()).await;
1364+
conversation.push_assistant_message(&mut os, AssistantMessage::new_response(None, "main response".to_string()), None);
1365+
conversation.transcript.push_back("main transcript".to_string());
1366+
1367+
let main_history_len = conversation.history.len();
1368+
let main_transcript_len = conversation.transcript.len();
1369+
1370+
// Enter tangent mode (toggle from normal to tangent)
1371+
conversation.enter_tangent_mode();
1372+
assert!(conversation.is_in_tangent_mode());
1373+
1374+
// History should be preserved for tangent (not cleared)
1375+
assert_eq!(conversation.history.len(), main_history_len);
1376+
assert_eq!(conversation.transcript.len(), main_transcript_len);
1377+
assert!(conversation.next_message.is_none());
1378+
1379+
// Add tangent conversation
1380+
conversation.set_next_user_message("tangent conversation".to_string()).await;
1381+
conversation.push_assistant_message(&mut os, AssistantMessage::new_response(None, "tangent response".to_string()), None);
1382+
1383+
// During tangent mode, history should have grown
1384+
assert_eq!(conversation.history.len(), main_history_len + 1);
1385+
assert_eq!(conversation.transcript.len(), main_transcript_len + 1);
1386+
1387+
// Exit tangent mode (toggle from tangent to normal)
1388+
conversation.exit_tangent_mode();
1389+
assert!(!conversation.is_in_tangent_mode());
1390+
1391+
// Main conversation should be restored (tangent additions discarded)
1392+
assert_eq!(conversation.history.len(), main_history_len); // Back to original length
1393+
assert_eq!(conversation.transcript.len(), main_transcript_len); // Back to original length
1394+
assert!(conversation.transcript.contains(&"main transcript".to_string()));
1395+
assert!(!conversation.transcript.iter().any(|t| t.contains("tangent")));
1396+
1397+
// Test multiple toggles
1398+
conversation.enter_tangent_mode();
1399+
assert!(conversation.is_in_tangent_mode());
1400+
conversation.exit_tangent_mode();
1401+
assert!(!conversation.is_in_tangent_mode());
1402+
}
12921403
}

0 commit comments

Comments
 (0)