From a4d46c81245d686b3e9102020c44ffc18c794f00 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:01:42 +0800 Subject: [PATCH] feat(terminal): give the composer a chat-box layout with attachments and / @ menus - A rounded, tinted frame whose hairline darkens on focus and on a file drag, a toolbar row with an attach button and a round send button that fills in once there is something to send. The key hints move into the send button's tooltip; the placeholder names the agent and what / and @ do. - Files become chips instead of words in the text: the attach button, a drop on the box, a pasted screenshot or copied file, and remote uploads all go through one paste_paths route that attaches while the box has focus. Backspace at the start of an empty caret removes the last chip. On send the paths follow the text as the shell words a drop would have typed. - `/` at the start of the message opens the agent's built-in commands (Claude Code, Codex, Gemini) plus Claude's custom commands from .claude/commands; `@` at the start of any word opens files from the pane's project, reusing the quick-open walk so it works on remote hosts too, spelled relative to the pane's directory. Up/Down move, Enter/Tab pick, Esc dismisses. - Shift+Tab is passed through to the agent to cycle its permission mode. - Enter with nothing to send no longer sends a bare Enter. No model picker, mode label or context meter: tty7 has no source for them that would stay true, and /model is one keystroke away in the menu. --- src/terminal/composer.rs | 987 ++++++++++++++++++++++++++++++++++----- src/terminal/view.rs | 43 +- src/ui/i18n/en.rs | 12 +- src/ui/i18n/ja.rs | 10 +- src/ui/i18n/mod.rs | 8 +- src/ui/i18n/zh.rs | 10 +- src/ui/search/files.rs | 2 +- src/ui/search/mod.rs | 2 +- 8 files changed, 938 insertions(+), 136 deletions(-) diff --git a/src/terminal/composer.rs b/src/terminal/composer.rs index bf9b1cec..7c0ae591 100644 --- a/src/terminal/composer.rs +++ b/src/terminal/composer.rs @@ -1,7 +1,7 @@ //! The message composer: a real text box docked under a pane that is running a //! coding agent, for writing a prompt the way a chat box lets you — mouse //! selection, the platform's own editing keys, an IME with its candidates in -//! place, pasting a screenshot — and handing it over whole. +//! place, files attached as chips — and handing it over whole. //! //! The agent's own TUI stays exactly where it was. The box docks *below* the //! grid rather than floating over it, so the grid gives up the rows the box @@ -10,27 +10,34 @@ //! //! Sending is typing, not an API. What leaves the box is written to the pane's //! pty as the text and then Enter, as two writes — see [`submit_plan`] for why -//! each agent needs to be spoken to slightly differently. +//! each agent needs to be spoken to slightly differently. That is also why the +//! box carries no model picker, no mode label and no context meter: tty7 can +//! only say what the agent has told it, and none of those are among the +//! things it tells. The agent's own `/model` is one `/` away, and Shift+Tab is +//! passed through to cycle its permission mode where it can be seen changing. use std::collections::{HashMap, VecDeque}; -use std::time::Duration; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; use alacritty_terminal::term::TermMode; use gpui::{ - Context, Entity, Focusable as _, MouseButton, MouseDownEvent, Subscription, Window, div, - prelude::*, px, + Context, Entity, ExternalPaths, Focusable as _, MouseButton, MouseDownEvent, SharedString, + Subscription, Window, div, prelude::*, px, }; -use gpui_component::input::{Input, InputEvent, InputState}; -use gpui_component::{ActiveTheme as _, h_flex}; +use gpui_component::input::{self, Input, InputEvent, InputState, RopeExt as _}; +use gpui_component::tooltip::Tooltip; +use gpui_component::{ActiveTheme as _, Icon, IconName, h_flex}; -use super::view::{TerminalView, types_cleanly}; +use super::view::{TerminalView, pasted_paths_text, types_cleanly}; use crate::core::cli_agent::{AgentStatus, CLIAgent}; -use crate::ui::host_ops::HostId; +use crate::ui::host_ops::{HostId, HostOps}; use crate::ui::i18n::{L10nKey, t, t_fmt}; +use crate::ui::search::files::{FileIndex, FileList, rank, walk}; -/// How tall the box may grow, in lines of text, before it scrolls instead. -/// Every row it grows is a row the agent loses, and a resize the agent has to -/// redraw for, so this stays well short of a page. +/// How tall the text may grow, in lines, before it scrolls instead. Every row +/// the box grows is a row the agent loses, and a resize it has to redraw for. const MAX_ROWS: usize = 8; /// The pause between two writes that must not arrive as one read. @@ -45,6 +52,15 @@ const SETTLE: Duration = Duration::from_millis(50); /// Copilot's input treats a CR that follows a paste too closely as part of it. const SETTLE_AFTER_PASTE_SLOW: Duration = Duration::from_millis(300); +/// Rows the `/` and `@` menu shows at once. +const MENU_ROWS: usize = 8; + +/// A file walk this recent answers the next `@` without walking again. +const FILES_FRESH_FOR: Duration = Duration::from_secs(30); + +/// Shift+Tab as a terminal sends it — what cycles an agent's permission mode. +const BACK_TAB: &[u8] = b"\x1b[Z"; + /// One write of a submission, and how long to wait before making it. #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct Step { @@ -118,6 +134,203 @@ pub(super) fn submit_plan(agent: CLIAgent, text: &str, bracketed: bool) -> Vec) -> String { + let text = text.trim_end(); + if attached.is_empty() { + return text.to_string(); + } + let words = pasted_paths_text(attached, shell); + let words = words.trim_end(); + match text.is_empty() { + true => words.to_string(), + false => format!("{text} {words}"), + } +} + +/// A `/` or `@` being typed at the caret: which one, where it starts, and what +/// follows it so far. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Trigger { + pub sigil: char, + /// Byte offset of the sigil. + pub start: usize, + pub query: String, +} + +/// The word the caret is at the end of, when it opens with `/` or `@`. +/// +/// A `/` counts only as the first thing in the message: that is the only +/// place an agent reads a slash command, and anywhere else it is a path or a +/// fraction. An `@` counts at the start of any word. +pub(super) fn trigger_at(text: &str, cursor: usize) -> Option { + let before = text.get(..cursor)?; + let start = before + .char_indices() + .rev() + .find(|(_, c)| c.is_whitespace()) + .map_or(0, |(i, c)| i + c.len_utf8()); + let word = &before[start..]; + let sigil = word.chars().next()?; + let valid = match sigil { + '/' => before[..start].trim().is_empty(), + '@' => true, + _ => false, + }; + valid.then(|| Trigger { + sigil, + start, + query: word[1..].to_string(), + }) +} + +/// One row of the `/` or `@` menu. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct MenuItem { + pub label: String, + pub detail: String, + /// What replaces the trigger word when the row is picked. + pub insert: String, + pub is_file: bool, +} + +/// The agent's own commands, the ones it ships with. Descriptions stay in the +/// agent's language — they name what its UI will do, in the words it uses. +fn builtin_commands(agent: CLIAgent) -> &'static [(&'static str, &'static str)] { + match agent { + CLIAgent::Claude => &[ + ("/compact", "Summarize and free up context"), + ("/clear", "Start a fresh conversation"), + ("/review", "Review the current changes"), + ("/model", "Switch model"), + ("/init", "Create a CLAUDE.md for this repo"), + ("/context", "Show context usage"), + ("/cost", "Show token usage"), + ("/resume", "Resume a previous conversation"), + ("/memory", "Edit memory files"), + ("/permissions", "Manage tool permissions"), + ("/mcp", "Manage MCP servers"), + ("/agents", "Manage subagents"), + ("/config", "Open settings"), + ("/help", "Show help"), + ], + CLIAgent::Codex => &[ + ("/model", "Choose model and reasoning effort"), + ("/approvals", "Choose what Codex may do without asking"), + ("/review", "Review the current changes"), + ("/new", "Start a new chat"), + ("/compact", "Summarize to free up context"), + ("/init", "Create an AGENTS.md for this repo"), + ("/diff", "Show the git diff"), + ("/mention", "Mention a file"), + ("/status", "Show session configuration and usage"), + ("/mcp", "List MCP tools"), + ], + CLIAgent::Gemini => &[ + ("/compress", "Summarize to free up context"), + ("/clear", "Clear the screen and history"), + ("/memory", "Manage memory"), + ("/chat", "Save or resume a conversation"), + ("/tools", "List available tools"), + ("/mcp", "List MCP servers"), + ("/stats", "Show session statistics"), + ("/help", "Show help"), + ], + _ => &[], + } +} + +/// Custom slash commands Claude Code reads from `.claude/commands` — the +/// project's under `cwd`, the user's under `home`. A file's name is its +/// command; subdirectories only namespace the description. +fn custom_commands(cwd: Option<&Path>, home: Option<&Path>) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = Vec::new(); + let roots = [ + ( + cwd.map(|c| c.join(".claude/commands")), + L10nKey::ComposerCmdProject, + ), + ( + home.map(|h| h.join(".claude/commands")), + L10nKey::ComposerCmdUser, + ), + ]; + for (dir, scope) in roots { + let Some(dir) = dir else { continue }; + let mut stack = vec![dir]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|e| e == "md") + && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) + { + let name = format!("/{stem}"); + if !out.iter().any(|(n, _)| *n == name) { + out.push((name, t(scope).to_string())); + } + } + } + } + } + out.sort(); + out +} + +/// The `/` menu for `query`: names starting with it first, in the order the +/// agent lists them, then names that merely contain it. +pub(super) fn command_items(commands: &[(String, String)], query: &str) -> Vec { + let q = query.to_lowercase(); + let starts = commands + .iter() + .filter(|(n, _)| n[1..].to_lowercase().starts_with(&q)); + let contains = commands.iter().filter(|(n, _)| { + let n = n[1..].to_lowercase(); + !n.starts_with(&q) && n.contains(&q) + }); + starts + .chain(contains) + .take(MENU_ROWS) + .map(|(name, detail)| MenuItem { + label: name.clone(), + detail: detail.clone(), + insert: name.clone(), + is_file: false, + }) + .collect() +} + +/// The `@` menu for `query`. The mention is spelled relative to `cwd` — which +/// is what the agent resolves it against — and in full for a file outside it. +fn file_items(index: &FileIndex, query: &str, cwd: Option<&Path>) -> Vec { + let spell = |path: &Path| -> String { + cwd.and_then(|c| path.strip_prefix(c).ok()) + .unwrap_or(path) + .to_string_lossy() + .into_owned() + }; + let item = |f: &crate::ui::search::files::IndexedFile| MenuItem { + label: f.name().to_string(), + detail: f.dir().to_string(), + insert: format!("@{}", spell(&f.path)), + is_file: true, + }; + match query.is_empty() { + // The walk is breadth-first, so its head is the top of the project. + true => index.files.iter().take(MENU_ROWS).map(item).collect(), + false => rank(index, query, MENU_ROWS) + .into_iter() + .map(|(_, f)| item(f)) + .collect(), + } +} + /// What a pane's composer holds that outlives the view drawing it. /// /// A view is rebuilt over the same daemon pane whenever its workspace is @@ -131,27 +344,41 @@ impl gpui::Global for ComposerMemory {} #[derive(Default, Clone)] struct Remembered { draft: String, + attached: Vec, open: bool, } +enum Files { + Unwalked, + Walking, + Ready(Arc, Instant), + Failed(Instant), +} + pub(super) struct Composer { pub(super) input: Entity, /// Whether the user wants the box on this pane. It is only *shown* while /// an agent is in the foreground, so a pane that goes back to its shell /// hides it and the next agent started there gets it back. open: bool, + /// Files to send with the message, spelled the way the pane's host reads + /// them — uploaded already, for a remote pane. + attached: Vec, /// Writes waiting their turn. Submissions queue rather than interleave, so /// a second message sent inside the first one's settle time cannot land /// its text between the first one's text and its Enter. queue: VecDeque, pumping: bool, - /// Text for the box from paths that cannot reach it directly — a paste - /// resolved on a background task, a file upload — taken in at the next - /// draw, which is the first place with a window to edit it in. - pending: Vec, /// Whose name the placeholder carries. A pane can run one agent after - /// another, and "Message Codex…" over Claude Code would be a lie. + /// another, and "Message Codex" over Claude Code would be a lie. named: Option, + /// The row of the `/` or `@` menu the keyboard is on. + highlighted: usize, + /// The text as it stood when Esc closed the menu. The menu stays shut + /// until the text moves on from there. + dismissed: Option, + files: Files, + commands: Option<(Vec<(String, String)>, Instant)>, _subs: Vec, } @@ -166,11 +393,12 @@ impl TerminalView { }; let entry = Remembered { draft: c.input.read(cx).value().to_string(), + attached: c.attached.clone(), open: c.open, }; let key = self.composer_key(); let memory = cx.default_global::(); - match entry.draft.is_empty() && !entry.open { + match entry.draft.is_empty() && entry.attached.is_empty() && !entry.open { true => memory.0.remove(&key), false => memory.0.insert(key, entry), }; @@ -196,10 +424,14 @@ impl TerminalView { self.composer = Some(Composer { input, open: remembered.open, + attached: remembered.attached, queue: VecDeque::new(), pumping: false, - pending: Vec::new(), named: None, + highlighted: 0, + dismissed: None, + files: Files::Unwalked, + commands: None, _subs: subs, }); } @@ -209,6 +441,18 @@ impl TerminalView { self.composer.as_ref().is_some_and(|c| c.open) && self.agent().is_some() } + fn focus_composer(&mut self, window: &mut Window, cx: &mut Context) { + let Some(c) = self.composer.as_ref() else { + return; + }; + let focus = c.input.read(cx).focus_handle(cx); + window.focus(&focus, cx); + // Said here as well as by the input's own Focus event, which only + // comes round on the next frame: what is about to land — a drop, a + // picked file — has to find the box already holding the keyboard. + self.composer_focused = true; + } + /// Open the box and put the caret in it; from inside it, close it; with /// it open but the terminal focused, go back into it. /// @@ -222,15 +466,15 @@ impl TerminalView { let Some(c) = self.composer.as_mut() else { return; }; - let focus = c.input.read(cx).focus_handle(cx); - match (c.open, focus.is_focused(window)) { + let focused = c.input.read(cx).focus_handle(cx).is_focused(window); + match (c.open, focused) { (true, true) => { c.open = false; window.focus(&self.focus_handle, cx); } _ => { c.open = true; - window.focus(&focus, cx); + self.focus_composer(window, cx); } } self.remember_composer(cx); @@ -246,28 +490,63 @@ impl TerminalView { cx.notify(); } - /// Text arriving for the box by way of the terminal's paste path — files - /// dropped on it, a pasted screenshot saved to disk, an upload to a remote - /// pane — so every route the terminal already knows lands in the box - /// instead when the box has the keyboard. - /// - /// Hands `text` back when the box is not the one taking it. - pub(super) fn composer_takes_paste( + /// Files arriving by way of the terminal's paste path — dropped on the + /// box, a copied file or a screenshot pasted into it, an upload to a + /// remote pane landing — become attachments while the box has the + /// keyboard. Hands them back otherwise, for the terminal to paste. + pub(super) fn composer_takes_paths( &mut self, - text: String, + spelled: Vec, cx: &mut Context, - ) -> Option { + ) -> Option> { if !self.composer_focused || !self.composer_shown() { - return Some(text); + return Some(spelled); } - self.composer.as_mut()?.pending.push(text); + let c = self.composer.as_mut()?; + for path in spelled { + if !c.attached.contains(&path) { + c.attached.push(path); + } + } + self.remember_composer(cx); cx.notify(); None } + fn detach(&mut self, index: usize, cx: &mut Context) { + if let Some(c) = self.composer.as_mut() + && index < c.attached.len() + { + c.attached.remove(index); + } + self.remember_composer(cx); + cx.notify(); + } + + /// The attach button: pick files on this computer, which then go the way + /// dropped files do — uploaded first for a remote pane. + fn pick_attachments(&mut self, window: &mut Window, cx: &mut Context) { + self.focus_composer(window, cx); + let rx = cx.prompt_for_paths(gpui::PathPromptOptions { + files: true, + directories: false, + multiple: true, + prompt: None, + }); + cx.spawn_in(window, async move |this, cx| { + if let Ok(Ok(Some(paths))) = rx.await { + let _ = this.update_in(cx, |view, window, cx| { + view.focus_composer(window, cx); + view.paste_local_paths(paths, cx); + }); + } + }) + .detach(); + } + /// Per-frame upkeep: restore a box the pane had open before this view - /// existed, take in pending text, and give the keyboard back to the - /// terminal when the agent the box was for has gone. + /// existed, keep the placeholder naming the agent in front, and give the + /// keyboard back to the terminal when the agent the box was for has gone. pub(super) fn sync_composer(&mut self, window: &mut Window, cx: &mut Context) { if self.composer.is_none() && self.agent().is_some() @@ -286,19 +565,15 @@ impl TerminalView { && c.named != Some(agent) { c.named = Some(agent); - let placeholder = t_fmt( - L10nKey::ComposerPlaceholder, - &[("agent", agent.display_name())], - ); + let key = match builtin_commands(agent).is_empty() { + true => L10nKey::ComposerPlaceholderFiles, + false => L10nKey::ComposerPlaceholder, + }; + let placeholder = t_fmt(key, &[("agent", agent.display_name())]); c.input.update(cx, |state, cx| { state.set_placeholder(placeholder, window, cx) }); } - if !c.pending.is_empty() { - let text = std::mem::take(&mut c.pending).concat(); - c.input - .update(cx, |state, cx| state.insert(text, window, cx)); - } if !shown && self.composer_focused { self.composer_focused = false; window.focus(&self.focus_handle, cx); @@ -313,7 +588,13 @@ impl TerminalView { cx: &mut Context, ) { match event { - InputEvent::Change => self.remember_composer(cx), + InputEvent::Change => { + if let Some(c) = self.composer.as_mut() { + c.highlighted = 0; + } + self.remember_composer(cx); + self.warm_menu_sources(cx); + } InputEvent::PressEnter { shift: false, .. } => self.submit_composer(window, cx), InputEvent::PressEnter { .. } => {} InputEvent::Focus => { @@ -327,6 +608,160 @@ impl TerminalView { } } + fn composer_trigger(&self, cx: &gpui::App) -> Option { + let c = self.composer.as_ref()?; + let state = c.input.read(cx); + let text = state.value(); + if c.dismissed.as_deref() == Some(text.as_ref()) { + return None; + } + trigger_at(&text, state.cursor()) + } + + /// Start what the menu about to open needs: the file walk for `@`, the + /// custom command list for `/`. Both are kept a while, so typing through + /// a query does not walk the project once per keystroke. + fn warm_menu_sources(&mut self, cx: &mut Context) { + let Some(trigger) = self.composer_trigger(cx) else { + return; + }; + let host_id = self.host_id(); + let local = host_id.is_local(); + let cwd = self.files_cwd(); + let agent = self.agent(); + match trigger.sigil { + '/' => { + let Some(c) = self.composer.as_mut() else { + return; + }; + if c.commands + .as_ref() + .is_some_and(|(_, at)| at.elapsed() < FILES_FRESH_FOR) + { + return; + } + let custom = match (local, agent) { + (true, Some(CLIAgent::Claude)) => { + let home = std::env::var_os("HOME").map(PathBuf::from); + custom_commands(cwd.as_deref(), home.as_deref()) + } + _ => Vec::new(), + }; + c.commands = Some((custom, Instant::now())); + } + '@' => { + let Some(c) = self.composer.as_mut() else { + return; + }; + let stale = match &c.files { + Files::Unwalked => true, + Files::Walking => false, + Files::Ready(_, at) | Files::Failed(at) => at.elapsed() > FILES_FRESH_FOR, + }; + let Some(cwd) = cwd.filter(|_| stale) else { + return; + }; + let Some(host) = crate::ui::host_registry::HostRegistry::lookup(cx, host_id) else { + return; + }; + if !matches!(c.files, Files::Ready(..)) { + c.files = Files::Walking; + } + let home = local + .then(|| std::env::var_os("HOME").map(PathBuf::from)) + .flatten(); + HostOps::run( + host, + cx, + move |h| walk(h, &[cwd], home.as_deref()), + |view: &mut TerminalView, list, cx| { + let Some(c) = view.composer.as_mut() else { + return; + }; + c.files = match list { + FileList::Ready(index) => Files::Ready(index, Instant::now()), + _ => Files::Failed(Instant::now()), + }; + cx.notify(); + }, + ); + } + _ => {} + } + } + + /// The `/` or `@` menu as it stands at the caret, if one is open. + fn composer_menu(&self, cx: &gpui::App) -> Option<(Trigger, Vec)> { + let trigger = self.composer_trigger(cx)?; + let c = self.composer.as_ref()?; + let items = match trigger.sigil { + '/' => { + let agent = self.agent()?; + let mut all: Vec<(String, String)> = builtin_commands(agent) + .iter() + .map(|(n, d)| (n.to_string(), d.to_string())) + .collect(); + if let Some((custom, _)) = &c.commands { + for (name, detail) in custom { + if !all.iter().any(|(n, _)| n == name) { + all.push((name.clone(), detail.clone())); + } + } + } + command_items(&all, &trigger.query) + } + _ => match &c.files { + Files::Ready(index, _) => { + file_items(index, &trigger.query, self.files_cwd().as_deref()) + } + _ => Vec::new(), + }, + }; + (!items.is_empty()).then_some((trigger, items)) + } + + /// Replace the word being typed with the picked row, and a space after it + /// so the next word starts clean. + fn pick_menu_item(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + let Some((trigger, items)) = self.composer_menu(cx) else { + return; + }; + let Some(item) = items.get(index) else { + return; + }; + let Some(c) = self.composer.as_ref() else { + return; + }; + let input = c.input.clone(); + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let cursor = state.cursor().min(text.len()); + let inserted = format!("{} ", item.insert); + let next = format!("{}{inserted}{}", &text[..trigger.start], &text[cursor..]); + let caret = trigger.start + inserted.len(); + state.set_value(next, window, cx); + let position = state.text().offset_to_position(caret); + state.set_cursor_position(position, window, cx); + }); + } + + fn step_menu(&mut self, forward: bool, cx: &mut Context) -> bool { + let Some((_, items)) = self.composer_menu(cx) else { + return false; + }; + let Some(c) = self.composer.as_mut() else { + return false; + }; + let n = items.len(); + let at = c.highlighted.min(n - 1); + c.highlighted = match forward { + true => (at + 1) % n, + false => (at + n - 1) % n, + }; + cx.notify(); + true + } + /// The agent is asking a question only its TUI can put — a permission /// prompt, a choice. Whatever the box sent would be taken as the answer, /// so Enter holds the message until the question is gone. @@ -335,30 +770,38 @@ impl TerminalView { .is_some_and(|s| s.status == AgentStatus::Waiting) } + fn composer_can_send(&self, cx: &gpui::App) -> bool { + self.composer + .as_ref() + .is_some_and(|c| !c.attached.is_empty() || !c.input.read(cx).value().trim().is_empty()) + && !self.agent_is_asking() + } + pub(super) fn submit_composer(&mut self, window: &mut Window, cx: &mut Context) { let Some(agent) = self.agent() else { return; }; - if self.agent_is_asking() { + if !self.composer_can_send(cx) { return; } - let Some(c) = self.composer.as_ref() else { + let shell = self.shell_program(); + let Some(c) = self.composer.as_mut() else { return; }; - let text = c.input.read(cx).value().to_string(); + let message = compose_message(&c.input.read(cx).value(), &c.attached, shell.as_deref()); let bracketed = self .terminal .term .lock() .mode() .contains(TermMode::BRACKETED_PASTE); - let steps = submit_plan(agent, &text, bracketed); + let steps = submit_plan(agent, &message, bracketed); + c.attached.clear(); + c.dismissed = None; + c.queue.extend(steps); c.input .update(cx, |state, cx| state.set_value("", window, cx)); self.remember_composer(cx); - if let Some(c) = self.composer.as_mut() { - c.queue.extend(steps); - } self.pump_composer(agent, cx); } @@ -424,28 +867,186 @@ impl TerminalView { let theme = cx.theme(); let focused = c.input.read(cx).focus_handle(cx).is_focused(window); let asking = self.agent_is_asking(); - let border = match (asking, focused) { + let can_send = self.composer_can_send(cx); + let ink = theme.foreground; + let muted = theme.muted_foreground; + let ring = match (asking, focused) { (true, _) => theme.warning, - (false, true) => theme.ring, - (false, false) => theme.border, - }; - let hint = match asking { - true => t_fmt( - L10nKey::ComposerAgentAsking, - &[("agent", agent.display_name())], - ), - false => t(L10nKey::ComposerKeys).to_string(), - }; - let hint_color = match asking { - true => theme.warning, - false => theme.muted_foreground, + (false, true) => ink.opacity(0.22), + (false, false) => ink.opacity(0.12), }; + let menu = self.composer_menu(cx); + + let chips = (!c.attached.is_empty()).then(|| { + h_flex() + .flex_wrap() + .gap(px(6.)) + .px(px(10.)) + .pt(px(10.)) + .children(c.attached.iter().enumerate().map(|(i, path)| { + let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string(); + h_flex() + .id(("composer-chip", i)) + .h(px(28.)) + .max_w(px(260.)) + .gap(px(7.)) + .pl(px(8.)) + .pr(px(4.)) + .rounded(px(7.)) + .bg(ink.opacity(0.05)) + .text_size(px(12.)) + .tooltip({ + let path: SharedString = path.clone().into(); + move |window, cx| Tooltip::new(path.clone()).build(window, cx) + }) + .child(Icon::new(IconName::File).size(px(12.)).text_color(muted)) + .child(div().min_w_0().truncate().child(name)) + .child( + div() + .id(("composer-chip-remove", i)) + .size(px(18.)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.)) + .hover(|s| s.bg(ink.opacity(0.07))) + .on_mouse_down(MouseButton::Left, |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }) + .on_click(cx.listener(move |this, _, _w, cx| this.detach(i, cx))) + .child(Icon::new(IconName::Close).size(px(9.)).text_color(muted)), + ) + })) + }); + + let attach = div() + .id("composer-attach") + .size(px(28.)) + .flex() + .items_center() + .justify_center() + .rounded(px(7.)) + .hover(|s| s.bg(ink.opacity(0.05))) + .tooltip(|window, cx| Tooltip::new(t(L10nKey::ComposerAttach)).build(window, cx)) + .on_click(cx.listener(|this, _, window, cx| this.pick_attachments(window, cx))) + .child(Icon::new(IconName::Plus).size(px(12.)).text_color(muted)); + + let notice = asking.then(|| { + div() + .min_w_0() + .truncate() + .text_size(px(12.)) + .text_color(theme.warning) + .child(t_fmt( + L10nKey::ComposerAgentAsking, + &[("agent", agent.display_name())], + )) + }); + + let send = div() + .id("composer-send") + .flex_none() + .size(px(28.)) + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(match can_send { + true => theme.primary, + false => ink.opacity(0.07), + }) + .tooltip(|window, cx| Tooltip::new(t(L10nKey::ComposerSendTip)).build(window, cx)) + .on_click(cx.listener(|this, _, window, cx| this.submit_composer(window, cx))) + .child( + Icon::new(IconName::ArrowUp) + .size(px(12.)) + .text_color(match can_send { + true => theme.primary_foreground, + false => muted.opacity(0.7), + }), + ); + + let popup = menu.as_ref().map(|(trigger, items)| { + let highlighted = c.highlighted.min(items.len() - 1); + let title = match trigger.sigil { + '/' => t(L10nKey::ComposerMenuCommands), + _ => t(L10nKey::ComposerMenuFiles), + }; + div() + .absolute() + .left_0() + .bottom_full() + .mb(px(6.)) + .w(px(380.)) + .max_w_full() + .p(px(5.)) + .flex() + .flex_col() + .gap(px(1.)) + .rounded(px(10.)) + .bg(theme.popover) + .border_1() + .border_color(theme.border) + .shadow_md() + .text_size(px(13.)) + .child( + div() + .h(px(24.)) + .px(px(9.)) + .flex() + .items_center() + .text_size(px(11.5)) + .text_color(muted) + .child(title), + ) + .children(items.iter().enumerate().map(|(i, item)| { + h_flex() + .id(("composer-menu", i)) + .h(px(30.)) + .px(px(9.)) + .gap(px(10.)) + .rounded(px(6.)) + .when(i == highlighted, |s| s.bg(ink.opacity(0.06))) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + this.pick_menu_item(i, window, cx); + }), + ) + .when(item.is_file, |s| { + s.child(Icon::new(IconName::File).size(px(12.)).text_color(muted)) + }) + .child( + div() + .flex_none() + .font_family(self.font.family.clone()) + .text_size(px(12.)) + .child(item.label.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_right() + .text_size(px(12.)) + .text_color(muted) + .child(item.detail.clone()), + ) + })) + }); + + let menu_open = menu.is_some(); Some( div() .id("composer") + .relative() .flex_none() .w_full() - .pt(px(6.)) + .pt(px(8.)) // The terminal surface this sits in focuses the grid on any // click and opens its own context menu on a right one. Neither // is right for a click on the box. @@ -455,11 +1056,8 @@ impl TerminalView { window.prevent_default(); // A click on the frame around the text, not only on // the text, is a click on the box. - if let Some(c) = this.composer.as_ref() - && !this.composer_focused - { - let focus = c.input.read(cx).focus_handle(cx); - window.focus(&focus, cx); + if !this.composer_focused { + this.focus_composer(window, cx); } }), ) @@ -471,39 +1069,118 @@ impl TerminalView { }), ) .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) - .capture_action( - cx.listener(|this, _: &gpui_component::input::Paste, _w, cx| { - // Text pastes are the box's own. A copied file or a - // screenshot has no text to paste, and the terminal - // already knows how to turn those into a path. - let Some(item) = cx.read_from_clipboard() else { - return; - }; - if item.text().is_some() && !super::view::clipboard_has_paths(&item) { + // Files dropped on the box are attachments, not words for the + // terminal's line; the surface's own drop handler would focus + // the grid first. + .on_drop(cx.listener(|this, paths: &ExternalPaths, window, cx| { + cx.stop_propagation(); + this.focus_composer(window, cx); + this.drop_files(paths, cx); + })) + .on_drop(cx.listener( + |this, drag: &crate::ui::file_tree::RemotePathDrag, window, cx| { + cx.stop_propagation(); + this.focus_composer(window, cx); + this.drop_remote_path(drag, cx); + }, + )) + .capture_action(cx.listener(|this, _: &input::Paste, _w, cx| { + // Text pastes are the box's own. A copied file or a + // screenshot has no text to paste, and the terminal + // already knows how to turn those into a path. + let Some(item) = cx.read_from_clipboard() else { + return; + }; + if item.text().is_some() && !super::view::clipboard_has_paths(&item) { + return; + } + cx.stop_propagation(); + this.paste_from_clipboard(cx); + })) + // Shift+Tab is the agent's: it cycles the permission mode, and + // the mode shows where it changes — in the TUI above. + .capture_action(cx.listener(|this, _: &input::OutdentInline, _w, cx| { + cx.stop_propagation(); + this.send_to_pty(BACK_TAB, cx); + })) + .capture_action(cx.listener(|this, _: &input::Backspace, _w, cx| { + let Some(c) = this.composer.as_ref() else { + return; + }; + let state = c.input.read(cx); + if state.cursor() != 0 || !state.selected_range().is_empty() { + return; + } + if let Some(last) = c.attached.len().checked_sub(1) { + cx.stop_propagation(); + this.detach(last, cx); + } + })) + .when(menu_open, |el| { + el.capture_action(cx.listener(|this, _: &input::MoveUp, _w, cx| { + if this.step_menu(false, cx) { + cx.stop_propagation(); + } + })) + .capture_action(cx.listener(|this, _: &input::MoveDown, _w, cx| { + if this.step_menu(true, cx) { + cx.stop_propagation(); + } + })) + .capture_action(cx.listener(|this, action: &input::Enter, window, cx| { + if action.shift { return; } cx.stop_propagation(); - this.paste_from_clipboard(cx); - }), - ) + let at = this.composer.as_ref().map_or(0, |c| c.highlighted); + this.pick_menu_item(at, window, cx); + })) + .capture_action(cx.listener(|this, _: &input::IndentInline, window, cx| { + cx.stop_propagation(); + let at = this.composer.as_ref().map_or(0, |c| c.highlighted); + this.pick_menu_item(at, window, cx); + })) + .capture_action(cx.listener( + |this, _: &input::Escape, _w, cx| { + cx.stop_propagation(); + if let Some(c) = this.composer.as_mut() { + c.dismissed = Some(c.input.read(cx).value().to_string()); + } + cx.notify(); + }, + )) + }) + .children(popup) .child( div() .flex() .flex_col() - .gap_1() - .px_3() - .py_2() - .rounded_lg() + .rounded(px(12.)) + .bg(ink.opacity(0.035)) .border_1() - .border_color(border) - .bg(theme.popover) - .child(Input::new(&c.input).appearance(false)) + .border_color(ring) + .drag_over::(move |s, _, _, _| { + s.border_color(ink.opacity(0.45)) + }) + .children(chips) + .child( + div() + .px(px(14.)) + .pt(px(12.)) + .pb(px(4.)) + .text_size(px(13.5)) + .line_height(px(20.)) + .child(Input::new(&c.input).appearance(false)), + ) .child( h_flex() - .justify_end() - .text_xs() - .text_color(hint_color) - .child(hint), + .h(px(40.)) + .px(px(6.)) + .gap(px(2.)) + .child(attach) + .children(notice) + .child(div().flex_1()) + .child(send), ), ) .into_any_element(), @@ -555,18 +1232,6 @@ mod tests { assert_eq!(bytes(&steps), [&b"!git status"[..], b"\r"]); } - #[test] - fn an_empty_box_sends_enter_alone() { - let steps = submit_plan(CLIAgent::Claude, " \n", true); - assert_eq!( - steps, - [Step { - delay: Duration::ZERO, - bytes: b"\r".to_vec() - }] - ); - } - #[test] fn escapes_cannot_close_the_paste_early() { let steps = submit_plan(CLIAgent::Claude, "a\n\x1b[201~b", true); @@ -580,4 +1245,112 @@ mod tests { let steps = submit_plan(CLIAgent::Copilot, "ab", true); assert_eq!(steps[1].delay, SETTLE); } + + #[test] + fn attachments_follow_the_text_as_shell_words() { + let attached = vec!["/tmp/a b.png".to_string(), "/src/x.rs".to_string()]; + assert_eq!( + compose_message("look at these\n", &attached, Some("zsh")), + "look at these '/tmp/a b.png' /src/x.rs" + ); + assert_eq!( + compose_message("", &attached[1..], Some("zsh")), + "/src/x.rs" + ); + assert_eq!( + compose_message("just text ", &[], Some("zsh")), + "just text" + ); + } + + #[test] + fn a_slash_opens_the_menu_only_at_the_start_of_the_message() { + let t = |text: &str| trigger_at(text, text.len()); + assert_eq!( + t("/comp"), + Some(Trigger { + sigil: '/', + start: 0, + query: "comp".into() + }) + ); + assert_eq!(t(" /").map(|t| t.start), Some(2)); + assert_eq!(t("see src/main.rs"), None); + assert_eq!(t("fix /tmp"), None); + } + + #[test] + fn an_at_opens_the_menu_at_the_start_of_any_word() { + let text = "look at @src/ma and"; + let cursor = "look at @src/ma".len(); + assert_eq!( + trigger_at(text, cursor), + Some(Trigger { + sigil: '@', + start: 8, + query: "src/ma".into() + }) + ); + assert_eq!(trigger_at("mail me@host", 12), None); + assert_eq!( + trigger_at("done @x ", 8), + None, + "a finished word is not a query" + ); + } + + #[test] + fn commands_that_start_with_the_query_come_before_ones_that_contain_it() { + let all: Vec<(String, String)> = [("/compact", ""), ("/clear", ""), ("/memory", "")] + .iter() + .map(|(n, d)| (n.to_string(), d.to_string())) + .collect(); + let labels = |q| { + command_items(&all, q) + .into_iter() + .map(|i| i.label) + .collect::>() + }; + assert_eq!(labels("c"), ["/compact", "/clear"]); + assert_eq!(labels("m"), ["/memory", "/compact"]); + assert_eq!(labels(""), ["/compact", "/clear", "/memory"]); + } + + #[test] + fn a_file_mention_is_spelled_from_the_panes_directory() { + let index = crate::ui::search::files::build_index( + &[PathBuf::from("/repo")], + vec![ + PathBuf::from("/repo/app/src/main.rs"), + PathBuf::from("/repo/README.md"), + ], + false, + ); + let items = file_items(&index, "main", Some(Path::new("/repo/app"))); + assert_eq!(items[0].insert, "@src/main.rs"); + let items = file_items(&index, "readme", Some(Path::new("/repo/app"))); + assert_eq!( + items[0].insert, "@/repo/README.md", + "outside the cwd: in full" + ); + } + + #[test] + fn claude_custom_commands_come_from_the_project_and_the_user() { + let dir = std::env::temp_dir().join(format!("tty7-cmds-{}", std::process::id())); + let project = dir.join("proj"); + let home = dir.join("home"); + std::fs::create_dir_all(project.join(".claude/commands/team")).unwrap(); + std::fs::create_dir_all(home.join(".claude/commands")).unwrap(); + std::fs::write(project.join(".claude/commands/ship.md"), "").unwrap(); + std::fs::write(project.join(".claude/commands/team/triage.md"), "").unwrap(); + std::fs::write(home.join(".claude/commands/standup.md"), "").unwrap(); + std::fs::write(home.join(".claude/commands/notes.txt"), "").unwrap(); + let names: Vec = custom_commands(Some(&project), Some(&home)) + .into_iter() + .map(|(n, _)| n) + .collect(); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(names, ["/ship", "/standup", "/triage"]); + } } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index bd3a952c..3052ed68 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1028,7 +1028,7 @@ fn clipboard_paths(item: &ClipboardItem) -> Vec { /// Paths as one line of shell words, trailing space included so whatever is /// typed next starts a word of its own. -fn pasted_paths_text(paths: &[String], shell: Option<&str>) -> String { +pub(super) fn pasted_paths_text(paths: &[String], shell: Option<&str>) -> String { let words: Vec = paths.iter().map(|p| quote_for_shell(p, shell)).collect(); format!("{} ", words.join(" ")) } @@ -2362,7 +2362,7 @@ impl TerminalView { /// answers from the platform — PowerShell on Windows, POSIX elsewhere. /// The one pane that guess is wrong for is a cmd.exe pane that has not /// reported in yet. - fn shell_program(&self) -> Option { + pub(super) fn shell_program(&self) -> Option { self.shell_spec.as_ref().map(|s| s.program.clone()) } @@ -3371,9 +3371,6 @@ impl TerminalView { if !self.accepts_input(cx) { return; } - let Some(text) = self.composer_takes_paste(text, cx) else { - return; - }; // Same reason as `commit_text`: what is pasted lands on the prompt, so // the prompt is what has to be on screen. Neither branch below moved // the viewport, and a paste is a bigger change than a keystroke to @@ -3636,29 +3633,42 @@ impl TerminalView { } } - fn drop_files(&mut self, paths: &ExternalPaths, cx: &mut Context) { + pub(super) fn drop_files(&mut self, paths: &ExternalPaths, cx: &mut Context) { self.paste_local_paths(paths.paths().to_vec(), cx); } /// A row dragged out of a remote Files tree names a file on that remote, /// not here: there is nothing to upload, and the path is pasted as the /// tree spells it. - fn drop_remote_path( + pub(super) fn drop_remote_path( &mut self, drag: &crate::ui::file_tree::RemotePathDrag, cx: &mut Context, ) { - let text = pasted_paths_text( - &[drag.path.to_string_lossy().into_owned()], - self.shell_program().as_deref(), - ); + self.paste_paths(vec![drag.path.to_string_lossy().into_owned()], cx); + } + + /// Paste paths already spelled the way the pane's host reads them: into + /// the composer as attachments when it has the keyboard, and as shell + /// words onto the line otherwise. Every route that turns files into + /// something to paste — a drop, a copied file, a screenshot, an upload to + /// a remote pane — ends here. + fn paste_paths(&mut self, spelled: Vec, cx: &mut Context) { + let Some(spelled) = self.composer_takes_paths(spelled, cx) else { + return; + }; + let text = pasted_paths_text(&spelled, self.shell_program().as_deref()); self.paste(text, cx); } /// Paste files that live on this machine so the pane's program can open /// them: uploaded first when the pane runs on an SSH host, renamed for a /// WSL pane, and pasted as they are everywhere else. - fn paste_local_paths(&mut self, paths: Vec, cx: &mut Context) { + pub(super) fn paste_local_paths( + &mut self, + paths: Vec, + cx: &mut Context, + ) { if paths.is_empty() { return; } @@ -3679,8 +3689,7 @@ impl TerminalView { .iter() .map(|p| local_path_for_pane(&p.to_string_lossy(), shares_localhost)) .collect(); - let text = pasted_paths_text(&spelled, self.shell_program().as_deref()); - self.paste(text, cx); + self.paste_paths(spelled, cx); } fn paste_clipboard_image(&mut self, img: &gpui::Image, cx: &mut Context) { @@ -3788,8 +3797,7 @@ impl TerminalView { }) .collect(); let pasted = this.update_in(cx, |view, window, cx| { - let text = pasted_paths_text(&spelled, view.shell_program().as_deref()); - view.paste(text, cx); + view.paste_paths(spelled, cx); for u in &uploads { if let Err(reason) = &u.started { view.warn_paste_upload_failed(&u.local, &host, reason, window, cx); @@ -3826,8 +3834,7 @@ impl TerminalView { .map(|p| p.to_string_lossy().into_owned()) .collect(); let _ = this.update_in(cx, |view, window, cx| { - let text = pasted_paths_text(&spelled, view.shell_program().as_deref()); - view.paste(text, cx); + view.paste_paths(spelled, cx); if let Some(first) = sources.first() { view.warn_paste_upload_failed(first, host, reason, window, cx); } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index a89f1bc7..05334203 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1855,10 +1855,14 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdClearScrollback => "Clear Scrollback", L10nKey::CmdFindInTerminal => "Find in Terminal…", L10nKey::CmdToggleComposer => "Toggle Message Composer", - L10nKey::ComposerPlaceholder => "Message {agent}…", - L10nKey::ComposerKeys => { - "Enter to send · Shift+Enter for a new line · Esc to go back to the terminal" - } + L10nKey::ComposerPlaceholder => "Message {agent} · @ files / commands", + L10nKey::ComposerPlaceholderFiles => "Message {agent} · @ files", + L10nKey::ComposerSendTip => "Send (Enter) · Shift+Enter for a new line", + L10nKey::ComposerAttach => "Attach files or images", + L10nKey::ComposerMenuCommands => "Commands", + L10nKey::ComposerMenuFiles => "Files", + L10nKey::ComposerCmdProject => "Project command", + L10nKey::ComposerCmdUser => "User command", L10nKey::ComposerAgentAsking => { "{agent} is asking something. Answer it in the terminal, then send." } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 4ee1cef1..5c3889ec 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1908,8 +1908,14 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdClearScrollback => "スクロールバックをクリア", L10nKey::CmdFindInTerminal => "ターミナル内を検索…", L10nKey::CmdToggleComposer => "メッセージ入力欄の切り替え", - L10nKey::ComposerPlaceholder => "{agent} にメッセージ…", - L10nKey::ComposerKeys => "Enter で送信 · Shift+Enter で改行 · Esc でターミナルに戻る", + L10nKey::ComposerPlaceholder => "{agent} にメッセージ · @ ファイル / コマンド", + L10nKey::ComposerPlaceholderFiles => "{agent} にメッセージ · @ ファイル", + L10nKey::ComposerSendTip => "送信(Enter) · Shift+Enter で改行", + L10nKey::ComposerAttach => "ファイルや画像を添付", + L10nKey::ComposerMenuCommands => "コマンド", + L10nKey::ComposerMenuFiles => "ファイル", + L10nKey::ComposerCmdProject => "プロジェクトのコマンド", + L10nKey::ComposerCmdUser => "ユーザーのコマンド", L10nKey::ComposerAgentAsking => { "{agent} が質問しています。ターミナルで答えてから送信してください。" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index f2bd201f..bc9c9827 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1569,7 +1569,13 @@ l10n_keys! { CmdFindInTerminal, CmdToggleComposer, ComposerPlaceholder, - ComposerKeys, + ComposerPlaceholderFiles, + ComposerSendTip, + ComposerAttach, + ComposerMenuCommands, + ComposerMenuFiles, + ComposerCmdProject, + ComposerCmdUser, ComposerAgentAsking, CmdFindNext, CmdFindPrevious, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 9ef02e7b..389fdf46 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1720,8 +1720,14 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdGitToggleGraph => "Git:显示 / 隐藏提交历史", L10nKey::CmdFindInTerminal => "在终端中查找…", L10nKey::CmdToggleComposer => "切换消息输入框", - L10nKey::ComposerPlaceholder => "给 {agent} 发消息…", - L10nKey::ComposerKeys => "Enter 发送 · Shift+Enter 换行 · Esc 回到终端", + L10nKey::ComposerPlaceholder => "给 {agent} 发消息 · @ 文件 / 命令", + L10nKey::ComposerPlaceholderFiles => "给 {agent} 发消息 · @ 文件", + L10nKey::ComposerSendTip => "发送(Enter) · Shift+Enter 换行", + L10nKey::ComposerAttach => "附加文件或图片", + L10nKey::ComposerMenuCommands => "命令", + L10nKey::ComposerMenuFiles => "文件", + L10nKey::ComposerCmdProject => "项目命令", + L10nKey::ComposerCmdUser => "用户命令", L10nKey::ComposerAgentAsking => "{agent} 正在等你回答,请先在终端里回答再发送。", L10nKey::CmdFindNext => "查找下一个", L10nKey::CmdFindPrevious => "查找上一个", diff --git a/src/ui/search/files.rs b/src/ui/search/files.rs index 8c1fc8f4..a81cd3b4 100644 --- a/src/ui/search/files.rs +++ b/src/ui/search/files.rs @@ -365,7 +365,7 @@ pub(crate) struct FileIndexStore { /// Walks the project the panes in `cwds` are in, on the host that has it. /// Runs off the UI thread. -fn walk(h: &dyn Host, cwds: &[PathBuf], home: Option<&Path>) -> FileList { +pub(crate) fn walk(h: &dyn Host, cwds: &[PathBuf], home: Option<&Path>) -> FileList { let resolved = cwds .iter() .map(|cwd| { diff --git a/src/ui/search/mod.rs b/src/ui/search/mod.rs index 9007d4ce..91a76d1d 100644 --- a/src/ui/search/mod.rs +++ b/src/ui/search/mod.rs @@ -9,7 +9,7 @@ //! - [`view`]: the modal — the tab row, the list, the theme picker. mod command; -mod files; +pub(crate) mod files; mod score; mod sources; mod view;