Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions _docs/config/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ If `XDG_CONFIG_HOME` is set, replace `~/.config` with `$XDG_CONFIG_HOME` in the
"$schema": "https://raw.githubusercontent.com/blankeos/crabcode/main/crabcode.schema.json",
"model": "openai/gpt-5.2",
"theme": "crabcode-orange",
"tui": {
"compactMode": true
},
"notifications": {
"terminalCondition": "unfocused",
"complete": {
Expand Down Expand Up @@ -65,6 +68,20 @@ If `XDG_CONFIG_HOME` is set, replace `~/.config` with `$XDG_CONFIG_HOME` in the
}
```

## Terminal UI

Use `tui.compactMode` to explicitly control compact mode and its sticky message header:

```jsonc title="crabcode.jsonc"
{
"tui": {
"compactMode": false
}
}
```

`compactMode` takes priority over the preference saved by `/compact-mode`. Without a config value, crabcode restores the last `/compact-mode` choice; new installations default to enabled. `compact_mode` is accepted as an alias.

## Permissions

crabcode reads the OpenCode-compatible `permission` field. Rules resolve to `allow`, `ask`, or `deny`, with later matching rules taking precedence.
Expand Down
1 change: 1 addition & 0 deletions _plans/__TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,3 +477,4 @@ I think this is how the TUI works already anyway right?
- [x] I wanna be able to type `/compact|` (imagine "|" is my cursor) and press `ctrl-t` or `ctrl-x m`.. Right now doing those kinda make me stay in the focus of the autosuggestions popover, so I think it's an event handling thing, but it's such an often thing that happens that I wanna make a special case for it.

- [x] I wanna make it scrollable even when doing ctrl-f find, with my mouse
- [ ] "providers" config, does it work
15 changes: 15 additions & 0 deletions crabcode.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,21 @@
"null"
]
},
"tui": {
"description": "Crabcode terminal UI settings. compactMode takes precedence over the saved /compact-mode preference.",
"type": "object",
"additionalProperties": false,
"properties": {
"compactMode": {
"description": "Enable sticky headers for compact mode. Alias: compact_mode.",
"type": "boolean"
},
"compact_mode": {
"description": "Alias for compactMode.",
"type": "boolean"
}
}
},
"tools": true,
"websearch": {
"anyOf": [
Expand Down
153 changes: 132 additions & 21 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1160,7 +1160,18 @@ impl App {
.unwrap_or_else(theme::Theme::load_builtin_default);
let colors = theme_for_colors.get_colors(true);

let chat_state = init_chat(chat, &agent, &colors);
let configured_compact_mode = loaded_config.merged_config.tui_compact_mode;
let persisted_compact_mode = if configured_compact_mode.is_none() {
prefs_dao
.as_ref()
.and_then(|dao| dao.get_compact_mode().ok().flatten())
} else {
None
};
let compact_mode = configured_compact_mode
.or(persisted_compact_mode)
.unwrap_or(true);
let chat_state = init_chat(chat, &agent, &colors, compact_mode);
let session_rename_dialog_state = init_session_rename_dialog(colors);
let runtime = crate::config::ConfigRuntime::from_merged(
&loaded_config.merged_config,
Expand Down Expand Up @@ -3064,10 +3075,13 @@ impl App {
}

fn current_chat_area(&self) -> Rect {
self.chat_area_for_size(self.last_frame_size)
// Prefer the last-rendered chat content rect (excludes compact chrome).
self.chat_state
.last_chat_area
.unwrap_or_else(|| self.chat_area_for_size(self.last_frame_size))
}

/// Forward chat mouse events while a permission/question dialog is open.
/// Forward chat mouse events while a permission/question dialog is open.
/// Clicks on dialog controls are handled by the dialog; everything else
/// (scroll + text selection) reaches the chat behind it.
fn forward_chat_mouse_through_dialog(&mut self, mouse: MouseEvent) {
Expand Down Expand Up @@ -3111,13 +3125,32 @@ impl App {
}
}

/// Region where a mouse wheel scrolls the chat. In compact mode this
/// extends above the chat content to include the 3-row header (and the
/// sticky overlay which sits inside the transcript top), so scrolling
/// works even when the pointer is over that chrome.
fn chat_scroll_region(&self) -> Rect {
let chat_area = self.current_chat_area();
if !self.chat_state.compact_mode {
return chat_area;
}
// Sticky is an overlay inside chat_area; only the header sits above it.
let top = chat_area.y.saturating_sub(3); // header rows
Rect {
x: chat_area.x,
y: top,
width: chat_area.width,
height: chat_area.bottom().saturating_sub(top),
}
}

pub fn handle_coalesced_mouse_scroll(&mut self, mouse: MouseEvent, notches: usize) {
if matches!(
self.overlay_focus,
OverlayFocus::None | OverlayFocus::FindBar
) && self.base_focus == BaseFocus::Chat
{
let chat_area = self.current_chat_area();
let chat_area = self.chat_scroll_region();
if chat_area.contains(Position::new(mouse.column, mouse.row))
&& self
.chat_state
Expand Down Expand Up @@ -4914,6 +4947,23 @@ impl App {
if self.base_focus == BaseFocus::Chat {
let chat_area = self.current_chat_area();

// Compact-mode sticky user message: click to scroll to that message.
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
&& mouse.modifiers.is_empty()
{
if let Some((sticky_rect, msg_idx)) = self.chat_state.sticky_click_target {
if sticky_rect.contains(Position::new(mouse.column, mouse.row)) {
self.chat_state.chat.scroll_to_message_index(msg_idx);
// Clear sticky state so the scrolled-to message re-enters
// the viewport cleanly without residual sticky chrome.
self.chat_state.sticky_message_index = None;
self.chat_state.sticky_click_target = None;
self.pending_chat_message_click = None;
return;
}
}
}

match mouse.kind {
MouseEventKind::Moved
if !self.chat_state.chat.has_selection()
Expand Down Expand Up @@ -6166,6 +6216,24 @@ impl App {
}
return;
}
if parsed.name == "compact-mode" && self.base_focus == BaseFocus::Chat {
self.chat_state.compact_mode = !self.chat_state.compact_mode;
if let Some(dao) = &self.prefs_dao {
if let Err(error) = dao.set_compact_mode(self.chat_state.compact_mode) {
eprintln!("Failed to persist compact mode preference: {error}");
}
}
push_toast(Toast::new(
if self.chat_state.compact_mode {
"Compact mode enabled"
} else {
"Compact mode disabled"
},
ToastLevel::Info,
Some(std::time::Duration::from_secs(2)),
));
return;
}
if self.command_matches(&parsed.name, "fork") && self.base_focus == BaseFocus::Chat
{
self.handle_fork_command(&parsed.args);
Expand Down Expand Up @@ -6395,6 +6463,24 @@ impl App {
}
return;
}
if parsed.name == "compact-mode" && self.base_focus == BaseFocus::Chat {
self.chat_state.compact_mode = !self.chat_state.compact_mode;
if let Some(dao) = &self.prefs_dao {
if let Err(error) = dao.set_compact_mode(self.chat_state.compact_mode) {
eprintln!("Failed to persist compact mode preference: {error}");
}
}
push_toast(Toast::new(
if self.chat_state.compact_mode {
"Compact mode enabled"
} else {
"Compact mode disabled"
},
ToastLevel::Info,
Some(std::time::Duration::from_secs(2)),
));
return;
}
if self.command_matches(&parsed.name, "fork") && self.base_focus == BaseFocus::Chat {
self.handle_fork_command(&parsed.args);
return;
Expand Down Expand Up @@ -8253,28 +8339,50 @@ impl App {
{
Ok(()) => {
let is_active = self.is_active_session(&session_id);
// Marker is appended last — pin to bottom so the
// "Context compacted" line is visible without jump.
let mut chat = self.chat_with_messages(messages.clone());
chat.scroll_to_bottom_on_next_render();
if let Some(marker_idx) = messages
// Marker is last in soft layout — pin to bottom so the
// "Context compacted" line is visible without mid-history jump.
// Prefer replace_messages on the live chat: rebuilding via
// chat_with_messages zeros content_height and can desync
// sticky/live scroll state until the next session load.
let marker_idx = messages
.iter()
.rposition(|m| crate::session::compaction::is_compaction_marker(m))
{
chat.set_highlighted_message(Some(marker_idx));
} else {
chat.clear_highlighted_message();
}

.rposition(|m| crate::session::compaction::is_compaction_marker(m));
if is_active {
self.chat_state.chat = chat.clone();
self.chat_state.chat.replace_messages(messages.clone());
self.chat_state.chat.scroll_to_bottom_on_next_render();
if let Some(marker_idx) = marker_idx {
self.chat_state
.chat
.set_highlighted_message(Some(marker_idx));
} else {
self.chat_state.chat.clear_highlighted_message();
}
}

// Always keep view-state in sync so reopen/switch
// shows the same compacted history + marker.
self.ensure_session_view_state(&session_id);
// Build parked chat before mutably borrowing session_view_states
// (chat_with_messages needs &self).
let parked_chat = if !is_active {
let mut view_chat = self.chat_with_messages(messages);
view_chat.scroll_to_bottom_on_next_render();
if let Some(marker_idx) = marker_idx {
view_chat.set_highlighted_message(Some(marker_idx));
} else {
view_chat.clear_highlighted_message();
}
Some(view_chat)
} else {
None
};
if let Some(state) = self.session_view_states.get_mut(&session_id) {
state.chat = chat;
// Keep the active session's live chat out of
// session_view_states (same invariant as
// load_session_view_state / switch_to_session).
// Never park an empty new_chat() here — that would
// wipe the marker on the next session restore.
if let Some(view_chat) = parked_chat {
state.chat = view_chat;
}
state.tool_calls = ToolCallViewState::default();
state.unread_completed = !is_active;
}
Expand Down Expand Up @@ -10616,6 +10724,9 @@ impl App {
&queued_messages,
&mut self.find_bar,
self.overlay_focus == OverlayFocus::None,
self.session_manager
.get_current_session()
.map(|s| s.title.as_str()),
);

if is_suggestions_visible(&self.suggestions_popup_state)
Expand Down Expand Up @@ -11204,7 +11315,7 @@ mod tests {
command_registry: registry,
session_manager: SessionManager::new(),
home_state: init_home(),
chat_state: init_chat(Chat::new(), "Build", &colors),
chat_state: init_chat(Chat::new(), "Build", &colors, true),
suggestions_popup_state: init_suggestions_popup(Popup::new()),
agents_dialog_state: init_agents_dialog("Select agent", vec![]),
models_dialog_state: init_models_dialog("Models", vec![]),
Expand Down
24 changes: 24 additions & 0 deletions src/command/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,22 @@ pub fn handle_compact<'a>(
})
}

pub fn handle_compact_mode<'a>(
parsed: &'a ParsedCommand,
_sm: &'a mut SessionManager,
) -> Pin<Box<dyn std::future::Future<Output = CommandResult> + Send + 'a>> {
let args = parsed.args.clone();

Box::pin(async move {
if !args.is_empty() {
return CommandResult::Error("Usage: /compact-mode".to_string());
}

// The app intercepts /compact-mode to toggle the chat_state.compact_mode flag.
CommandResult::Success(String::new())
})
}

pub fn handle_fork<'a>(
parsed: &'a ParsedCommand,
_sm: &'a mut SessionManager,
Expand Down Expand Up @@ -978,6 +994,14 @@ pub fn register_all_commands(registry: &mut Registry) {
chat_only: true,
});

registry.register(Command {
name: "compact-mode".to_string(),
description: "Toggle compact mode (sticky header + latest user message)".to_string(),
handler: handle_compact_mode,
hidden_tokens: vec![],
chat_only: true,
});

registry.register(Command {
name: "fork".to_string(),
description: "Fork the current session".to_string(),
Expand Down
27 changes: 27 additions & 0 deletions src/config/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ pub enum ProviderTimeout {
#[derive(Debug, Clone, Default)]
pub struct MergedConfig {
pub theme: Option<String>,
pub tui_compact_mode: Option<bool>,
pub model: Option<String>,
pub small_model: Option<String>,
pub default_agent: Option<String>,
Expand Down Expand Up @@ -1060,6 +1061,7 @@ fn crabcode_allowed_keys() -> BTreeSet<&'static str> {
out.insert("notifications");
out.insert("images");
out.insert("websearch");
out.insert("tui");
out
}

Expand Down Expand Up @@ -1314,6 +1316,12 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M
}
}

out.tui_compact_mode = obj
.get("tui")
.and_then(Value::as_object)
.and_then(|tui| tui.get("compactMode").or_else(|| tui.get("compact_mode")))
.and_then(Value::as_bool);

if let Some(Value::String(model)) = obj.get("model") {
if !model.trim().is_empty() {
out.model = Some(model.trim().to_string());
Expand Down Expand Up @@ -2589,6 +2597,7 @@ fn collect_unimplemented_keys(merged: &Value) -> Vec<String> {
"notifications",
"images",
"websearch",
"tui",
"instructions",
"tools",
"watcher",
Expand Down Expand Up @@ -2671,6 +2680,24 @@ mod tests {
);
}

#[test]
fn parses_tui_compact_mode_aliases() {
let mut diagnostics = ConfigDiagnostics::default();
let config = parse_merged_config(
&json!({ "tui": { "compactMode": false } }),
&mut diagnostics,
);

assert_eq!(config.tui_compact_mode, Some(false));

let config = parse_merged_config(
&json!({ "tui": { "compact_mode": true } }),
&mut diagnostics,
);

assert_eq!(config.tui_compact_mode, Some(true));
}

#[test]
fn parses_enabled_and_disabled_providers() {
let mut diagnostics = ConfigDiagnostics::default();
Expand Down
Loading
Loading