Refactor codebase: improve formatting consistency, simplify message rendering, and optimize cursor and visual selection handling logic across panels.
This commit is contained in:
@@ -51,10 +51,10 @@ pub struct ChatApp {
|
||||
clipboard: String, // Vim-style clipboard for yank/paste
|
||||
command_buffer: String, // Buffer for command mode input
|
||||
visual_start: Option<(usize, usize)>, // Visual mode selection start (row, col) for Input panel
|
||||
visual_end: Option<(usize, usize)>, // Visual mode selection end (row, col) for scrollable panels
|
||||
focused_panel: FocusedPanel, // Currently focused panel for scrolling
|
||||
chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col)
|
||||
thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col)
|
||||
visual_end: Option<(usize, usize)>, // Visual mode selection end (row, col) for scrollable panels
|
||||
focused_panel: FocusedPanel, // Currently focused panel for scrolling
|
||||
chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col)
|
||||
thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col)
|
||||
}
|
||||
|
||||
impl ChatApp {
|
||||
@@ -364,9 +364,7 @@ impl ChatApp {
|
||||
self.sync_buffer_to_textarea();
|
||||
// Set a visible selection style
|
||||
self.textarea.set_selection_style(
|
||||
Style::default()
|
||||
.bg(Color::LightBlue)
|
||||
.fg(Color::Black)
|
||||
Style::default().bg(Color::LightBlue).fg(Color::Black),
|
||||
);
|
||||
// Start visual selection at current cursor position
|
||||
self.textarea.start_selection();
|
||||
@@ -374,7 +372,8 @@ impl ChatApp {
|
||||
}
|
||||
FocusedPanel::Chat | FocusedPanel::Thinking => {
|
||||
// For scrollable panels, start selection at cursor position
|
||||
let cursor = if matches!(self.focused_panel, FocusedPanel::Chat) {
|
||||
let cursor = if matches!(self.focused_panel, FocusedPanel::Chat)
|
||||
{
|
||||
self.chat_cursor
|
||||
} else {
|
||||
self.thinking_cursor
|
||||
@@ -463,7 +462,8 @@ impl ChatApp {
|
||||
if self.chat_cursor.0 + 1 < max_lines {
|
||||
self.chat_cursor.0 += 1;
|
||||
// Scroll if cursor moves below viewport
|
||||
let viewport_bottom = self.auto_scroll.scroll + self.viewport_height;
|
||||
let viewport_bottom =
|
||||
self.auto_scroll.scroll + self.viewport_height;
|
||||
if self.chat_cursor.0 >= viewport_bottom {
|
||||
self.on_scroll(1);
|
||||
}
|
||||
@@ -473,7 +473,8 @@ impl ChatApp {
|
||||
let max_lines = self.thinking_scroll.content_len;
|
||||
if self.thinking_cursor.0 + 1 < max_lines {
|
||||
self.thinking_cursor.0 += 1;
|
||||
let viewport_bottom = self.thinking_scroll.scroll + self.thinking_viewport_height;
|
||||
let viewport_bottom = self.thinking_scroll.scroll
|
||||
+ self.thinking_viewport_height;
|
||||
if self.thinking_cursor.0 >= viewport_bottom {
|
||||
self.on_scroll(1);
|
||||
}
|
||||
@@ -486,133 +487,135 @@ impl ChatApp {
|
||||
}
|
||||
// Horizontal cursor movement
|
||||
(KeyCode::Left, KeyModifiers::NONE)
|
||||
| (KeyCode::Char('h'), KeyModifiers::NONE) => {
|
||||
match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if self.chat_cursor.1 > 0 {
|
||||
self.chat_cursor.1 -= 1;
|
||||
}
|
||||
| (KeyCode::Char('h'), KeyModifiers::NONE) => match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if self.chat_cursor.1 > 0 {
|
||||
self.chat_cursor.1 -= 1;
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if self.thinking_cursor.1 > 0 {
|
||||
self.thinking_cursor.1 -= 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if self.thinking_cursor.1 > 0 {
|
||||
self.thinking_cursor.1 -= 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
(KeyCode::Right, KeyModifiers::NONE)
|
||||
| (KeyCode::Char('l'), KeyModifiers::NONE) => {
|
||||
match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if let Some(line) = self.get_line_at_row(self.chat_cursor.0) {
|
||||
let max_col = line.chars().count();
|
||||
if self.chat_cursor.1 < max_col {
|
||||
self.chat_cursor.1 += 1;
|
||||
}
|
||||
| (KeyCode::Char('l'), KeyModifiers::NONE) => match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if let Some(line) = self.get_line_at_row(self.chat_cursor.0) {
|
||||
let max_col = line.chars().count();
|
||||
if self.chat_cursor.1 < max_col {
|
||||
self.chat_cursor.1 += 1;
|
||||
}
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) {
|
||||
let max_col = line.chars().count();
|
||||
if self.thinking_cursor.1 < max_col {
|
||||
self.thinking_cursor.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) {
|
||||
let max_col = line.chars().count();
|
||||
if self.thinking_cursor.1 < max_col {
|
||||
self.thinking_cursor.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// Word movement
|
||||
(KeyCode::Char('w'), KeyModifiers::NONE) => {
|
||||
match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if let Some(new_col) = self.find_next_word_boundary(self.chat_cursor.0, self.chat_cursor.1) {
|
||||
self.chat_cursor.1 = new_col;
|
||||
}
|
||||
(KeyCode::Char('w'), KeyModifiers::NONE) => match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if let Some(new_col) = self
|
||||
.find_next_word_boundary(self.chat_cursor.0, self.chat_cursor.1)
|
||||
{
|
||||
self.chat_cursor.1 = new_col;
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if let Some(new_col) = self.find_next_word_boundary(self.thinking_cursor.0, self.thinking_cursor.1) {
|
||||
self.thinking_cursor.1 = new_col;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(KeyCode::Char('e'), KeyModifiers::NONE) => {
|
||||
match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if let Some(new_col) = self.find_word_end(self.chat_cursor.0, self.chat_cursor.1) {
|
||||
self.chat_cursor.1 = new_col;
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if let Some(new_col) = self.find_next_word_boundary(
|
||||
self.thinking_cursor.0,
|
||||
self.thinking_cursor.1,
|
||||
) {
|
||||
self.thinking_cursor.1 = new_col;
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if let Some(new_col) = self.find_word_end(self.thinking_cursor.0, self.thinking_cursor.1) {
|
||||
self.thinking_cursor.1 = new_col;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(KeyCode::Char('b'), KeyModifiers::NONE) => {
|
||||
match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if let Some(new_col) = self.find_prev_word_boundary(self.chat_cursor.0, self.chat_cursor.1) {
|
||||
self.chat_cursor.1 = new_col;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
(KeyCode::Char('e'), KeyModifiers::NONE) => match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if let Some(new_col) =
|
||||
self.find_word_end(self.chat_cursor.0, self.chat_cursor.1)
|
||||
{
|
||||
self.chat_cursor.1 = new_col;
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if let Some(new_col) = self.find_prev_word_boundary(self.thinking_cursor.0, self.thinking_cursor.1) {
|
||||
self.thinking_cursor.1 = new_col;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(KeyCode::Char('^'), KeyModifiers::SHIFT) => {
|
||||
match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if let Some(line) = self.get_line_at_row(self.chat_cursor.0) {
|
||||
let first_non_blank = line.chars().position(|c| !c.is_whitespace()).unwrap_or(0);
|
||||
self.chat_cursor.1 = first_non_blank;
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if let Some(new_col) = self
|
||||
.find_word_end(self.thinking_cursor.0, self.thinking_cursor.1)
|
||||
{
|
||||
self.thinking_cursor.1 = new_col;
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) {
|
||||
let first_non_blank = line.chars().position(|c| !c.is_whitespace()).unwrap_or(0);
|
||||
self.thinking_cursor.1 = first_non_blank;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
(KeyCode::Char('b'), KeyModifiers::NONE) => match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if let Some(new_col) = self
|
||||
.find_prev_word_boundary(self.chat_cursor.0, self.chat_cursor.1)
|
||||
{
|
||||
self.chat_cursor.1 = new_col;
|
||||
}
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if let Some(new_col) = self.find_prev_word_boundary(
|
||||
self.thinking_cursor.0,
|
||||
self.thinking_cursor.1,
|
||||
) {
|
||||
self.thinking_cursor.1 = new_col;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
(KeyCode::Char('^'), KeyModifiers::SHIFT) => match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if let Some(line) = self.get_line_at_row(self.chat_cursor.0) {
|
||||
let first_non_blank =
|
||||
line.chars().position(|c| !c.is_whitespace()).unwrap_or(0);
|
||||
self.chat_cursor.1 = first_non_blank;
|
||||
}
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) {
|
||||
let first_non_blank =
|
||||
line.chars().position(|c| !c.is_whitespace()).unwrap_or(0);
|
||||
self.thinking_cursor.1 = first_non_blank;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// Line start/end navigation
|
||||
(KeyCode::Char('0'), KeyModifiers::NONE) | (KeyCode::Home, KeyModifiers::NONE) => {
|
||||
match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
self.chat_cursor.1 = 0;
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
self.thinking_cursor.1 = 0;
|
||||
}
|
||||
_ => {}
|
||||
(KeyCode::Char('0'), KeyModifiers::NONE)
|
||||
| (KeyCode::Home, KeyModifiers::NONE) => match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
self.chat_cursor.1 = 0;
|
||||
}
|
||||
}
|
||||
(KeyCode::Char('$'), KeyModifiers::NONE) | (KeyCode::End, KeyModifiers::NONE) => {
|
||||
match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if let Some(line) = self.get_line_at_row(self.chat_cursor.0) {
|
||||
self.chat_cursor.1 = line.chars().count();
|
||||
}
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) {
|
||||
self.thinking_cursor.1 = line.chars().count();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
FocusedPanel::Thinking => {
|
||||
self.thinking_cursor.1 = 0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
(KeyCode::Char('$'), KeyModifiers::NONE)
|
||||
| (KeyCode::End, KeyModifiers::NONE) => match self.focused_panel {
|
||||
FocusedPanel::Chat => {
|
||||
if let Some(line) = self.get_line_at_row(self.chat_cursor.0) {
|
||||
self.chat_cursor.1 = line.chars().count();
|
||||
}
|
||||
}
|
||||
FocusedPanel::Thinking => {
|
||||
if let Some(line) = self.get_line_at_row(self.thinking_cursor.0) {
|
||||
self.thinking_cursor.1 = line.chars().count();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// Half-page scrolling
|
||||
(KeyCode::Char('d'), KeyModifiers::CONTROL) => {
|
||||
self.scroll_half_page_down();
|
||||
@@ -647,7 +650,8 @@ impl ChatApp {
|
||||
if !self.clipboard.is_empty() {
|
||||
// Always paste into Input panel
|
||||
let current_lines = self.textarea.lines().to_vec();
|
||||
let clipboard_lines: Vec<String> = self.clipboard.lines().map(|s| s.to_string()).collect();
|
||||
let clipboard_lines: Vec<String> =
|
||||
self.clipboard.lines().map(|s| s.to_string()).collect();
|
||||
|
||||
// Append clipboard content to current input
|
||||
let mut new_lines = current_lines;
|
||||
@@ -692,7 +696,7 @@ impl ChatApp {
|
||||
self.pending_key = None;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
InputMode::Editing => match (key.code, key.modifiers) {
|
||||
(KeyCode::Esc, KeyModifiers::NONE) => {
|
||||
// Sync textarea content to input buffer before leaving edit mode
|
||||
@@ -733,10 +737,12 @@ impl ChatApp {
|
||||
self.textarea.move_cursor(tui_textarea::CursorMove::End);
|
||||
}
|
||||
(KeyCode::Char('w'), m) if m.contains(KeyModifiers::CONTROL) => {
|
||||
self.textarea.move_cursor(tui_textarea::CursorMove::WordForward);
|
||||
self.textarea
|
||||
.move_cursor(tui_textarea::CursorMove::WordForward);
|
||||
}
|
||||
(KeyCode::Char('b'), m) if m.contains(KeyModifiers::CONTROL) => {
|
||||
self.textarea.move_cursor(tui_textarea::CursorMove::WordBack);
|
||||
self.textarea
|
||||
.move_cursor(tui_textarea::CursorMove::WordBack);
|
||||
}
|
||||
(KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => {
|
||||
// Redo - history next
|
||||
@@ -774,7 +780,8 @@ impl ChatApp {
|
||||
let (row, _) = self.textarea.cursor();
|
||||
if let Some(line) = self.textarea.lines().get(row) {
|
||||
self.clipboard = line.clone();
|
||||
self.status = format!("Yanked line ({} chars)", self.clipboard.len());
|
||||
self.status =
|
||||
format!("Yanked line ({} chars)", self.clipboard.len());
|
||||
}
|
||||
}
|
||||
self.textarea.cancel_selection();
|
||||
@@ -812,7 +819,10 @@ impl ChatApp {
|
||||
// Can't delete from read-only panels, just yank
|
||||
if let Some(yanked) = self.yank_from_panel() {
|
||||
self.clipboard = yanked;
|
||||
self.status = format!("Yanked {} chars (read-only panel)", self.clipboard.len());
|
||||
self.status = format!(
|
||||
"Yanked {} chars (read-only panel)",
|
||||
self.clipboard.len()
|
||||
);
|
||||
} else {
|
||||
self.status = "Nothing to yank".to_string();
|
||||
}
|
||||
@@ -877,11 +887,12 @@ impl ChatApp {
|
||||
// Move selection down (increase end row)
|
||||
if let Some((row, col)) = self.visual_end {
|
||||
// Get max lines for the current panel
|
||||
let max_lines = if matches!(self.focused_panel, FocusedPanel::Chat) {
|
||||
self.auto_scroll.content_len
|
||||
} else {
|
||||
self.thinking_scroll.content_len
|
||||
};
|
||||
let max_lines =
|
||||
if matches!(self.focused_panel, FocusedPanel::Chat) {
|
||||
self.auto_scroll.content_len
|
||||
} else {
|
||||
self.thinking_scroll.content_len
|
||||
};
|
||||
if row + 1 < max_lines {
|
||||
self.visual_end = Some((row + 1, col));
|
||||
// Scroll if needed to keep selection visible
|
||||
@@ -894,7 +905,8 @@ impl ChatApp {
|
||||
(KeyCode::Char('w'), KeyModifiers::NONE) => {
|
||||
match self.focused_panel {
|
||||
FocusedPanel::Input => {
|
||||
self.textarea.move_cursor(tui_textarea::CursorMove::WordForward);
|
||||
self.textarea
|
||||
.move_cursor(tui_textarea::CursorMove::WordForward);
|
||||
}
|
||||
FocusedPanel::Chat | FocusedPanel::Thinking => {
|
||||
// Move selection forward by word
|
||||
@@ -909,7 +921,8 @@ impl ChatApp {
|
||||
(KeyCode::Char('b'), KeyModifiers::NONE) => {
|
||||
match self.focused_panel {
|
||||
FocusedPanel::Input => {
|
||||
self.textarea.move_cursor(tui_textarea::CursorMove::WordBack);
|
||||
self.textarea
|
||||
.move_cursor(tui_textarea::CursorMove::WordBack);
|
||||
}
|
||||
FocusedPanel::Chat | FocusedPanel::Thinking => {
|
||||
// Move selection backward by word
|
||||
@@ -997,7 +1010,8 @@ impl ChatApp {
|
||||
self.command_buffer.clear();
|
||||
self.mode = InputMode::Normal;
|
||||
}
|
||||
(KeyCode::Char(c), KeyModifiers::NONE) | (KeyCode::Char(c), KeyModifiers::SHIFT) => {
|
||||
(KeyCode::Char(c), KeyModifiers::NONE)
|
||||
| (KeyCode::Char(c), KeyModifiers::SHIFT) => {
|
||||
self.command_buffer.push(c);
|
||||
self.status = format!(":{}", self.command_buffer);
|
||||
}
|
||||
@@ -1282,7 +1296,9 @@ impl ChatApp {
|
||||
|
||||
// Step 1: Add user message to conversation immediately (synchronous)
|
||||
let message = self.controller.input_buffer_mut().commit_to_history();
|
||||
self.controller.conversation_mut().push_user_message(message.clone());
|
||||
self.controller
|
||||
.conversation_mut()
|
||||
.push_user_message(message.clone());
|
||||
|
||||
// Auto-scroll to bottom when sending a message
|
||||
self.auto_scroll.stick_to_bottom = true;
|
||||
@@ -1308,7 +1324,11 @@ impl ChatApp {
|
||||
parameters.stream = self.controller.config().general.enable_streaming;
|
||||
|
||||
// Step 2: Start the actual request
|
||||
match self.controller.send_request_with_current_conversation(parameters).await {
|
||||
match self
|
||||
.controller
|
||||
.send_request_with_current_conversation(parameters)
|
||||
.await
|
||||
{
|
||||
Ok(SessionOutcome::Complete(_response)) => {
|
||||
self.stop_loading_animation();
|
||||
self.status = "Ready".to_string();
|
||||
@@ -1323,10 +1343,7 @@ impl ChatApp {
|
||||
self.status = "Generating response...".to_string();
|
||||
|
||||
self.spawn_stream(response_id, stream);
|
||||
match self
|
||||
.controller
|
||||
.mark_stream_placeholder(response_id, "▌")
|
||||
{
|
||||
match self.controller.mark_stream_placeholder(response_id, "▌") {
|
||||
Ok(_) => self.error = None,
|
||||
Err(err) => {
|
||||
self.error = Some(format!("Could not set response placeholder: {}", err));
|
||||
@@ -1354,7 +1371,6 @@ impl ChatApp {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn sync_selected_model_index(&mut self) {
|
||||
let current_model_id = self.controller.selected_model().to_string();
|
||||
let filtered_models: Vec<&ModelInfo> = self
|
||||
@@ -1409,7 +1425,8 @@ impl ChatApp {
|
||||
|
||||
pub fn advance_loading_animation(&mut self) {
|
||||
if self.is_loading {
|
||||
self.loading_animation_frame = (self.loading_animation_frame + 1) % 8; // 8-frame animation
|
||||
self.loading_animation_frame = (self.loading_animation_frame + 1) % 8;
|
||||
// 8-frame animation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1452,7 +1469,8 @@ impl ChatApp {
|
||||
};
|
||||
|
||||
let content_to_display = if matches!(role, Role::Assistant) {
|
||||
let (content_without_think, _) = formatter.extract_thinking(&message.content);
|
||||
let (content_without_think, _) =
|
||||
formatter.extract_thinking(&message.content);
|
||||
content_without_think
|
||||
} else {
|
||||
message.content.clone()
|
||||
@@ -1505,7 +1523,8 @@ impl ChatApp {
|
||||
}
|
||||
|
||||
fn yank_from_panel(&self) -> Option<String> {
|
||||
let (start_pos, end_pos) = if let (Some(s), Some(e)) = (self.visual_start, self.visual_end) {
|
||||
let (start_pos, end_pos) = if let (Some(s), Some(e)) = (self.visual_start, self.visual_end)
|
||||
{
|
||||
// Normalize selection
|
||||
if s.0 < e.0 || (s.0 == e.0 && s.1 <= e.1) {
|
||||
(s, e)
|
||||
@@ -1522,7 +1541,13 @@ impl ChatApp {
|
||||
|
||||
pub fn update_thinking_from_last_message(&mut self) {
|
||||
// Extract thinking from the last assistant message
|
||||
if let Some(last_msg) = self.conversation().messages.iter().rev().find(|m| matches!(m.role, Role::Assistant)) {
|
||||
if let Some(last_msg) = self
|
||||
.conversation()
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| matches!(m.role, Role::Assistant))
|
||||
{
|
||||
let (_, thinking) = self.formatter().extract_thinking(&last_msg.content);
|
||||
// Only set stick_to_bottom if content actually changed (to enable auto-scroll during streaming)
|
||||
let content_changed = self.current_thinking != thinking;
|
||||
@@ -1540,8 +1565,6 @@ impl ChatApp {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fn spawn_stream(&mut self, message_id: Uuid, mut stream: owlen_core::provider::ChatStream) {
|
||||
let sender = self.session_tx.clone();
|
||||
self.streaming.insert(message_id);
|
||||
|
||||
Reference in New Issue
Block a user