feat(composer): prompt stash — Ctrl+S parks, /stash list+pop (#440)
A stash is a side-channel from history: it holds drafts the user parked deliberately instead of submissions made in the past (which live in `composer_history.rs`). * `crates/tui/src/composer_stash.rs` — JSONL-backed store at `~/.deepseek/composer_stash.jsonl`. One JSON object per line with `ts` (RFC 3339) and `text`. Self-healing parser drops malformed lines instead of poisoning the file. Multi-line drafts round-trip intact via JSON's newline escaping. Capped at 200 entries; oldest pruned at push time. Empty / whitespace-only text is silently dropped. * `crates/tui/src/commands/stash.rs` — `/stash list` renders the stash with one-line previews and timestamps; `/stash pop` restores the most recently parked draft into the composer (LIFO) and rewrites the file. `/park` aliases `/stash`. * Composer Ctrl+S handler in `tui/ui.rs` — pushes the current draft onto the stash, clears the composer, and surfaces a toast confirming the action so the no-op-feel doesn't fool users into thinking nothing happened. Empty composers are a no-op so a stray Ctrl+S can't pollute the file. * New `KbStashDraft` keybinding entry registered in the help overlay; localized in en, ja, zh-Hans, pt-BR. Tests: 7 unit tests in `composer_stash.rs` cover round-trip, LIFO pop, empty-on-pop, drop-empty-text, multi-line preservation, malformed-line resilience, and cap pruning. 4 unit tests in `commands/stash.rs` cover the preview helper's truncation, multi-line first-line behavior, and empty-input handling.
This commit is contained in:
@@ -194,6 +194,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
`~/.deepseek/audit.log` can correlate large-output episodes
|
||||
with disk-usage growth in `~/.deepseek/tool_outputs/`. Fires
|
||||
in both the sequential and parallel tool paths.
|
||||
- **Prompt stash** (#440) — Ctrl+S in the composer parks the
|
||||
current draft to a JSONL-backed stash at
|
||||
`~/.deepseek/composer_stash.jsonl` (no-op on empty composer).
|
||||
`/stash list` shows parked drafts (oldest first, with one-line
|
||||
previews and timestamps); `/stash pop` restores the most
|
||||
recently parked draft into the composer (LIFO). Self-healing
|
||||
parser drops malformed lines instead of poisoning the stash.
|
||||
Capped at 200 entries; multiline drafts round-trip intact via
|
||||
JSON's newline escaping.
|
||||
- **RLM tool family** (#512) — `rlm` tool cards map to
|
||||
`ToolFamily::Rlm` and render `rlm`, not `swarm`. Stale "swarm"
|
||||
wording cleaned out of docs / comments / tests.
|
||||
|
||||
@@ -20,6 +20,7 @@ mod review;
|
||||
mod session;
|
||||
pub mod share;
|
||||
mod skills;
|
||||
mod stash;
|
||||
mod task;
|
||||
mod user_commands;
|
||||
|
||||
@@ -461,6 +462,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"models" => core::models(app),
|
||||
"provider" => provider::provider(app, arg),
|
||||
"queue" | "queued" => queue::queue(app, arg),
|
||||
"stash" | "park" => stash::stash(app, arg),
|
||||
"subagents" | "agents" => core::subagents(app),
|
||||
"links" | "dashboard" | "api" => core::deepseek_links(app),
|
||||
"home" | "stats" | "overview" => core::home_dashboard(app),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! `/stash` slash command — list / pop parked composer drafts (#440).
|
||||
//!
|
||||
//! See `crates/tui/src/composer_stash.rs` for the on-disk format
|
||||
//! and persistence rules. The slash command is the user-facing
|
||||
//! surface; Ctrl+S in the composer is the corresponding push entry
|
||||
//! point.
|
||||
|
||||
use crate::composer_stash;
|
||||
use crate::tui::app::App;
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
/// Top-level dispatch for `/stash`. Subcommands:
|
||||
///
|
||||
/// * `/stash` — same as `/stash list`.
|
||||
/// * `/stash list` — show parked drafts, oldest first.
|
||||
/// * `/stash pop` — restore the most recently parked draft into
|
||||
/// the composer; the popped entry is removed from disk.
|
||||
pub fn stash(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let sub = arg.map(str::trim).unwrap_or("list").to_ascii_lowercase();
|
||||
match sub.as_str() {
|
||||
"" | "list" | "ls" | "show" => list(),
|
||||
"pop" | "restore" => pop(app),
|
||||
other => CommandResult::error(format!(
|
||||
"unknown subcommand `{other}`. Try `/stash list` or `/stash pop`."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn list() -> CommandResult {
|
||||
let entries = composer_stash::load_stash();
|
||||
if entries.is_empty() {
|
||||
return CommandResult::message(
|
||||
"Stash empty. Press Ctrl+S in the composer to park the current draft.",
|
||||
);
|
||||
}
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("{} parked draft(s):\n\n", entries.len()));
|
||||
for (idx, entry) in entries.iter().enumerate() {
|
||||
let preview = preview_first_line(&entry.text, 80);
|
||||
let ts = if entry.ts.is_empty() {
|
||||
"(no ts)".to_string()
|
||||
} else {
|
||||
entry.ts.clone()
|
||||
};
|
||||
out.push_str(&format!(" {idx}. [{ts}] {preview}\n"));
|
||||
}
|
||||
out.push_str("\nUse `/stash pop` to restore the most recent draft.");
|
||||
CommandResult::message(out)
|
||||
}
|
||||
|
||||
fn pop(app: &mut App) -> CommandResult {
|
||||
match composer_stash::pop_stash() {
|
||||
Some(entry) => {
|
||||
// Replace the current composer contents with the popped
|
||||
// draft. We don't merge — replacing is the predictable
|
||||
// behaviour and matches the "restore the parked draft"
|
||||
// mental model. Mirror the queue-edit pattern for the
|
||||
// cursor reset.
|
||||
app.input = entry.text.clone();
|
||||
app.cursor_position = app.input.len();
|
||||
let preview = preview_first_line(&entry.text, 60);
|
||||
CommandResult::message(format!("Restored stashed draft: {preview}"))
|
||||
}
|
||||
None => CommandResult::message("Stash empty — nothing to pop."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a one-line preview of `text`, capped at `max_chars`.
|
||||
/// Multi-line drafts get a single-line summary so the listing
|
||||
/// stays scannable.
|
||||
fn preview_first_line(text: &str, max_chars: usize) -> String {
|
||||
let head = text.lines().next().unwrap_or("").trim();
|
||||
if head.chars().count() <= max_chars {
|
||||
return head.to_string();
|
||||
}
|
||||
let mut out: String = head.chars().take(max_chars.saturating_sub(1)).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preview_first_line_truncates_to_cap() {
|
||||
let body = "x".repeat(200);
|
||||
let p = preview_first_line(&body, 10);
|
||||
assert_eq!(p.chars().count(), 10);
|
||||
assert!(p.ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_first_line_keeps_short_input_intact() {
|
||||
assert_eq!(preview_first_line("short", 50), "short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_first_line_only_uses_first_line_of_multiline() {
|
||||
let body = "first line of the draft\nsecond line that's longer\nthird";
|
||||
assert_eq!(preview_first_line(body, 80), "first line of the draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_first_line_handles_empty_input() {
|
||||
assert_eq!(preview_first_line("", 50), "");
|
||||
assert_eq!(preview_first_line(" ", 50), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Parked-draft stash for the composer (#440).
|
||||
//!
|
||||
//! A stash is a side-channel from history: it holds drafts the user
|
||||
//! parked deliberately (Ctrl+S) instead of submissions made in the
|
||||
//! past (which live in `composer_history.rs`). Pop semantics make it
|
||||
//! a LIFO — the most recent stash comes back first.
|
||||
//!
|
||||
//! ## On-disk format
|
||||
//!
|
||||
//! `~/.deepseek/composer_stash.jsonl` — one JSON object per line:
|
||||
//!
|
||||
//! ```jsonl
|
||||
//! {"ts":"2026-05-04T01:23:45Z","text":"draft here"}
|
||||
//! ```
|
||||
//!
|
||||
//! Self-healing parser: malformed lines are skipped silently so a
|
||||
//! single bad write doesn't corrupt the rest of the stash. The
|
||||
//! parser doesn't require any specific field order; only `text` is
|
||||
//! mandatory.
|
||||
//!
|
||||
//! ## Why JSONL and not a plain text file?
|
||||
//!
|
||||
//! Drafts can contain newlines (they're prompts, not single-line
|
||||
//! commands), so a `\n`-delimited plain file would mangle multi-line
|
||||
//! drafts. JSONL escapes newlines inside JSON strings without
|
||||
//! ambiguity and the timestamp / future fields land cleanly.
|
||||
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const STASH_FILE_NAME: &str = "composer_stash.jsonl";
|
||||
|
||||
/// Hard cap so a runaway script can't fill the user's home with
|
||||
/// parked drafts. Older entries are pruned at push time when the
|
||||
/// stash exceeds this count.
|
||||
pub const MAX_STASH_ENTRIES: usize = 200;
|
||||
|
||||
/// One parked draft. Fields are `#[serde(default)]` so legacy /
|
||||
/// truncated records still parse instead of poisoning the stash.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StashedDraft {
|
||||
/// RFC 3339 timestamp; omitted on legacy records.
|
||||
#[serde(default)]
|
||||
pub ts: String,
|
||||
/// The parked text. Required — entries with no `text` are
|
||||
/// dropped during load (treated as malformed).
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
fn default_stash_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".deepseek").join(STASH_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load every stashed draft from disk in the order they were
|
||||
/// written (oldest first). Self-healing: malformed lines are
|
||||
/// dropped silently. Returns an empty vec when the file doesn't
|
||||
/// exist.
|
||||
#[must_use]
|
||||
pub fn load_stash() -> Vec<StashedDraft> {
|
||||
let Some(path) = default_stash_path() else {
|
||||
return Vec::new();
|
||||
};
|
||||
load_stash_from(&path)
|
||||
}
|
||||
|
||||
fn load_stash_from(path: &Path) -> Vec<StashedDraft> {
|
||||
let Ok(file) = fs::File::open(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
BufReader::new(file)
|
||||
.lines()
|
||||
.map_while(Result::ok)
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.filter_map(|line| serde_json::from_str::<StashedDraft>(&line).ok())
|
||||
.filter(|draft| !draft.text.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Push a new draft onto the stash. Empty / whitespace-only text
|
||||
/// is silently dropped so a stray Ctrl+S on an empty composer
|
||||
/// doesn't pollute the file. Failures are logged but never
|
||||
/// propagated — stash is a UX nicety, not a correctness concern.
|
||||
pub fn push_stash(text: &str) {
|
||||
let Some(path) = default_stash_path() else {
|
||||
return;
|
||||
};
|
||||
push_stash_to(&path, text);
|
||||
}
|
||||
|
||||
fn push_stash_to(path: &Path, text: &str) {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(parent) = path.parent()
|
||||
&& let Err(err) = fs::create_dir_all(parent)
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to create composer stash dir {}: {err}",
|
||||
parent.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut entries = load_stash_from(path);
|
||||
entries.push(StashedDraft {
|
||||
ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
||||
text: text.to_string(),
|
||||
});
|
||||
if entries.len() > MAX_STASH_ENTRIES {
|
||||
let excess = entries.len() - MAX_STASH_ENTRIES;
|
||||
entries.drain(0..excess);
|
||||
}
|
||||
write_stash_to(path, &entries);
|
||||
}
|
||||
|
||||
/// Remove and return the most recently pushed draft, if any.
|
||||
/// Rewrites the on-disk file with the remaining entries.
|
||||
#[must_use]
|
||||
pub fn pop_stash() -> Option<StashedDraft> {
|
||||
let path = default_stash_path()?;
|
||||
pop_stash_from(&path)
|
||||
}
|
||||
|
||||
fn pop_stash_from(path: &Path) -> Option<StashedDraft> {
|
||||
let mut entries = load_stash_from(path);
|
||||
let popped = entries.pop()?;
|
||||
write_stash_to(path, &entries);
|
||||
Some(popped)
|
||||
}
|
||||
|
||||
fn write_stash_to(path: &Path, entries: &[StashedDraft]) {
|
||||
let mut payload = String::new();
|
||||
for entry in entries {
|
||||
match serde_json::to_string(entry) {
|
||||
Ok(line) => {
|
||||
payload.push_str(&line);
|
||||
payload.push('\n');
|
||||
}
|
||||
Err(err) => {
|
||||
// A draft that round-trips through serde shouldn't
|
||||
// fail to serialize, but belt-and-suspenders so a
|
||||
// weird codepoint in `text` doesn't blow the file
|
||||
// away mid-write.
|
||||
tracing::warn!("Skipping stash entry due to serialize failure: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(err) = crate::utils::write_atomic(path, payload.as_bytes()) {
|
||||
tracing::warn!(
|
||||
"Failed to persist composer stash at {}: {err}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn temp_stash_path() -> (TempDir, PathBuf) {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("composer_stash.jsonl");
|
||||
(tmp, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_and_load_round_trip() {
|
||||
let (_tmp, path) = temp_stash_path();
|
||||
push_stash_to(&path, "first draft");
|
||||
push_stash_to(&path, "second draft");
|
||||
let entries = load_stash_from(&path);
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].text, "first draft");
|
||||
assert_eq!(entries[1].text, "second draft");
|
||||
assert!(!entries[1].ts.is_empty(), "timestamp stamped on push");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pop_returns_lifo_and_rewrites_file() {
|
||||
let (_tmp, path) = temp_stash_path();
|
||||
push_stash_to(&path, "first");
|
||||
push_stash_to(&path, "second");
|
||||
let popped = pop_stash_from(&path).expect("non-empty stash");
|
||||
assert_eq!(popped.text, "second");
|
||||
let remaining = load_stash_from(&path);
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].text, "first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pop_on_empty_stash_returns_none() {
|
||||
let (_tmp, path) = temp_stash_path();
|
||||
assert!(pop_stash_from(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_text_is_dropped() {
|
||||
let (_tmp, path) = temp_stash_path();
|
||||
push_stash_to(&path, "");
|
||||
push_stash_to(&path, " \n ");
|
||||
assert!(load_stash_from(&path).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_drafts_are_preserved_intact() {
|
||||
let (_tmp, path) = temp_stash_path();
|
||||
let multiline = "first line\nsecond line\n third line";
|
||||
push_stash_to(&path, multiline);
|
||||
let entries = load_stash_from(&path);
|
||||
assert_eq!(entries.len(), 1);
|
||||
// Multi-line text round-trips because JSON escapes the newlines.
|
||||
assert_eq!(entries[0].text, multiline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_lines_are_skipped_and_valid_lines_survive() {
|
||||
let (_tmp, path) = temp_stash_path();
|
||||
// Mix of valid JSON, garbage, and partial-write truncation.
|
||||
let raw = "\
|
||||
{\"ts\":\"2026-05-04T01:23:45Z\",\"text\":\"good one\"}
|
||||
this is not json
|
||||
{\"text\":\"good two\"}
|
||||
{\"ts\":\"2026-05-04T01:24:00Z\"
|
||||
{\"text\":\"\"}
|
||||
{}
|
||||
";
|
||||
std::fs::write(&path, raw).unwrap();
|
||||
let entries = load_stash_from(&path);
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].text, "good one");
|
||||
assert_eq!(entries[1].text, "good two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_prunes_oldest_at_push_time() {
|
||||
let (_tmp, path) = temp_stash_path();
|
||||
for i in 0..(MAX_STASH_ENTRIES + 5) {
|
||||
push_stash_to(&path, &format!("draft {i}"));
|
||||
}
|
||||
let entries = load_stash_from(&path);
|
||||
assert_eq!(entries.len(), MAX_STASH_ENTRIES);
|
||||
// Oldest survivors are `5..` because the first 5 were pruned.
|
||||
assert_eq!(entries[0].text, "draft 5");
|
||||
assert_eq!(
|
||||
entries[entries.len() - 1].text,
|
||||
format!("draft {}", MAX_STASH_ENTRIES + 5 - 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -299,6 +299,7 @@ pub enum MessageId {
|
||||
KbJumpLineStartEnd,
|
||||
KbDeleteChar,
|
||||
KbClearDraft,
|
||||
KbStashDraft,
|
||||
KbSearchHistory,
|
||||
KbInsertNewline,
|
||||
KbSendDraft,
|
||||
@@ -482,6 +483,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::KbJumpLineStartEnd,
|
||||
MessageId::KbDeleteChar,
|
||||
MessageId::KbClearDraft,
|
||||
MessageId::KbStashDraft,
|
||||
MessageId::KbSearchHistory,
|
||||
MessageId::KbInsertNewline,
|
||||
MessageId::KbSendDraft,
|
||||
@@ -848,6 +850,7 @@ fn english(id: MessageId) -> &'static str {
|
||||
"Delete character before / after the cursor, or remove selected attachment"
|
||||
}
|
||||
MessageId::KbClearDraft => "Clear the current draft",
|
||||
MessageId::KbStashDraft => "Stash the current draft (`/stash pop` to restore)",
|
||||
MessageId::KbSearchHistory => "Search prompt history and recover local drafts",
|
||||
MessageId::KbInsertNewline => "Insert a newline in the composer",
|
||||
MessageId::KbSendDraft => "Send the current draft",
|
||||
@@ -1117,6 +1120,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::KbJumpLineStartEnd => "行の先頭/末尾へジャンプ",
|
||||
MessageId::KbDeleteChar => "カーソル前/後の文字を削除、または選択中の添付を削除",
|
||||
MessageId::KbClearDraft => "現在の下書きをクリア",
|
||||
MessageId::KbStashDraft => "現在の下書きをスタッシュ(`/stash pop`で復元)",
|
||||
MessageId::KbSearchHistory => "プロンプト履歴を検索してローカル下書きを復元",
|
||||
MessageId::KbInsertNewline => "コンポーザーに改行を挿入",
|
||||
MessageId::KbSendDraft => "現在の下書きを送信",
|
||||
@@ -1359,6 +1363,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::KbJumpLineStartEnd => "跳转到行首/行尾",
|
||||
MessageId::KbDeleteChar => "删除光标前/后的字符,或移除已选附件",
|
||||
MessageId::KbClearDraft => "清空当前草稿",
|
||||
MessageId::KbStashDraft => "暂存当前草稿(用 `/stash pop` 恢复)",
|
||||
MessageId::KbSearchHistory => "搜索提示历史并恢复本地草稿",
|
||||
MessageId::KbInsertNewline => "在输入框中插入换行",
|
||||
MessageId::KbSendDraft => "发送当前草稿",
|
||||
@@ -1627,6 +1632,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
|
||||
"Excluir caractere antes / depois do cursor, ou remover anexo selecionado"
|
||||
}
|
||||
MessageId::KbClearDraft => "Limpar rascunho atual",
|
||||
MessageId::KbStashDraft => "Estacionar rascunho atual (`/stash pop` restaura)",
|
||||
MessageId::KbSearchHistory => "Buscar histórico de prompts e recuperar rascunhos locais",
|
||||
MessageId::KbInsertNewline => "Inserir nova linha no compositor",
|
||||
MessageId::KbSendDraft => "Enviar rascunho atual",
|
||||
|
||||
@@ -19,6 +19,7 @@ mod command_safety;
|
||||
mod commands;
|
||||
mod compaction;
|
||||
mod composer_history;
|
||||
mod composer_stash;
|
||||
mod config;
|
||||
mod config_ui;
|
||||
mod core;
|
||||
|
||||
@@ -134,6 +134,11 @@ pub const KEYBINDINGS: &[KeybindingEntry] = &[
|
||||
description_id: crate::localization::MessageId::KbClearDraft,
|
||||
section: KeybindingSection::Editing,
|
||||
},
|
||||
KeybindingEntry {
|
||||
chord: "Ctrl+S",
|
||||
description_id: crate::localization::MessageId::KbStashDraft,
|
||||
section: KeybindingSection::Editing,
|
||||
},
|
||||
KeybindingEntry {
|
||||
chord: "Alt+R",
|
||||
description_id: crate::localization::MessageId::KbSearchHistory,
|
||||
|
||||
@@ -2395,6 +2395,22 @@ async fn run_event_loop(
|
||||
{
|
||||
app.delete_word_backward();
|
||||
}
|
||||
KeyCode::Char('s') | KeyCode::Char('S')
|
||||
if key.modifiers == KeyModifiers::CONTROL && !app.input.is_empty() =>
|
||||
{
|
||||
// #440: park the current draft to the persistent
|
||||
// stash and clear the composer. Empty composers
|
||||
// are a no-op so a stray Ctrl+S can't pollute the
|
||||
// file. Surface a toast so the user sees the
|
||||
// confirmation (no-op feels broken otherwise).
|
||||
crate::composer_stash::push_stash(&app.input);
|
||||
app.clear_input_recoverable();
|
||||
app.push_status_toast(
|
||||
"Draft stashed — `/stash pop` to restore",
|
||||
StatusToastLevel::Info,
|
||||
Some(3_000),
|
||||
);
|
||||
}
|
||||
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
// #379: context-sensitive Ctrl+Y.
|
||||
// When the composer has content → emacs-style yank
|
||||
|
||||
Reference in New Issue
Block a user