1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
//! Parses chat file-attachment slash-commands in the input box
//! (`/file attach <path>`, `/file remove <name|#N>`, `/file list`,
//! `/file open <name|#N>`, `/file folder`). Pure,
//! testable logic: the chat screen calls it on send; a recognized command turns
//! into an intent, while an unrecognized string goes out as a regular message.
//! Error messages are localized in the interface language (axis B) — the caller
//! passes its `Locale`.
//!
//! [`FileProgress`] also lives here — the command-outcome type, defined in the
//! `features` layer so both `app` (emits events) and `screens` (renders notes)
//! can use it without breaking FSD's dependency direction (the same arrangement
//! as [`RagProgress`](super::rag_ingest::RagProgress)).
//!
//! See docs/file-attachments.md, spec §9.7.
use crate::entities::attachment::{AttachMode, AttachmentInfo};
use crate::shared::i18n::Locale;
/// A recognized `/file` command.
#[derive(Debug, Clone, PartialEq)]
pub enum FileCommand {
/// Attach a file to the current chat.
Attach { path: String },
/// Remove an attachment by display name, path, or `#N` position.
Remove { target: String },
/// Show what is attached to the current chat.
List,
/// Open one of the chat's files in the OS, by the handle `/file list` shows
/// (fork F9, docs/history/sandbox-file-exchange.md §13 U1).
Open { target: String },
/// Open the chat's stored-files folder in the OS (`/file folder`).
Folder,
}
/// Outcome of a `/file` command, for the feed note / status.
#[derive(Debug, Clone, PartialEq)]
pub enum FileProgress {
/// A file was attached: its card + the chat's new inline total.
Attached {
info: AttachmentInfo,
total_tokens: usize,
/// The encoding the file was read in, when it was not UTF-8 — the note names it
/// (docs/research/local-file-encoding.md F4b).
read_as: Option<&'static str>,
},
/// An attachment was removed; `source` is set when another attachment had its name,
/// so the note can say which one went.
Removed {
name: String,
source: Option<String>,
},
/// A stored file's listing was dropped and our copy of it deleted
/// (docs/history/sandbox-file-exchange.md §11 S11).
RemovedStored { name: String },
/// An attached document that kept its original went as one item: the attachment and
/// our copy of the file, never the user's own (fork F8a, §12 T9).
RemovedPair { name: String },
/// Files a tool stored landed in the chat: their names and the folder they are in
/// (§11 S7).
Saved { names: Vec<String>, dir: String },
/// `/file attach` kept the user's own file with the chat because its text is not the
/// file — a binary (fork F8a, §12 T9). No attachment was made: the note says what can
/// read it instead.
StoredFile {
name: String,
bytes: u64,
mime: String,
dir: String,
},
/// The chat's files as one numbered list (`/file list`): attachments, then the stored
/// files no attachment links, then the images its messages carry — the numbering
/// `/file remove` takes and `python_exec` names (docs/history/sandbox-file-exchange.md §12 T2).
/// All three empty — nothing attached, stored or shown. `dir` is the stored files'
/// folder.
Listed {
items: Vec<AttachmentInfo>,
stored: Vec<StoredInfo>,
images: Vec<crate::entities::message_image::ImageInfo>,
dir: String,
},
/// A file was handed to the desktop's handler (`/file open`, fork F9). The path is
/// printed too, always: a launch that fails on the way is then one copy away from
/// working (§13 U6).
Opened { name: String, path: String },
/// A folder was opened: the chat's own (`/file folder`), or — when `instead_of` is
/// set — the folder of a file whose type is not one a handler may run (§13 U3).
OpenedFolder {
path: String,
instead_of: Option<OpenedInstead>,
},
/// Building the semantic index over a by-reference file is under way
/// (a banner with a spinner; `done`/`total` are chunks).
Indexing {
name: String,
done: usize,
total: usize,
},
/// Building a file's index ended — the banner goes. Its outcome note
/// ([`Self::Indexed`] / [`Self::IndexSkipped`]) may come later: for a file a
/// tool attached mid-turn it is held until the file lands, so it never splits
/// the streaming reply (docs/research/attachment-birth-turn.md G3).
IndexEnded { name: String },
/// The semantic index for a file is ready — `attachment_search` can reach it.
Indexed { name: String, chunks: usize },
/// No index was built (no embedder configured, or a write failed). Not an
/// error: the pinned block and `attachment_read` keep working in full.
IndexSkipped { name: String, reason: String },
/// The command failed (no such file, undecodable content, no active chat…).
Failed(String),
}
/// The file a folder stood in for ([`FileProgress::OpenedFolder`]), and who put it there.
///
/// The reason a type is refused is the same for everyone — the allowlist is by type — but
/// what the note may honestly *say* is not: "a file the assistant wrote could run under
/// its handler" is the reason for a call's `run.bat`, and untrue of a `.py` the user
/// attached themselves.
#[derive(Debug, Clone, PartialEq)]
pub struct OpenedInstead {
pub name: String,
/// Written by a `python_exec` call — stored from `/w/out`, or adopted from the folder
/// as one. `false` for the user's own file: an attachment, its kept original, an image.
pub by_a_call: bool,
}
/// A stored file as `/file list` shows it (docs/history/sandbox-file-exchange.md §11 S11).
#[derive(Debug, Clone, PartialEq)]
pub struct StoredInfo {
pub name: String,
pub bytes: u64,
pub mime: String,
/// The chat lists a file its folder no longer holds.
pub missing: bool,
}
/// Tries to parse an input string as a `/file` command.
///
/// - `None` — the string isn't a `/file` command (doesn't start with `/file`):
/// it should be sent as a regular message.
/// - `Some(Ok(cmd))` — a correct command.
/// - `Some(Err(msg))` — a `/file` command with a syntax error (a localized hint
/// in `msg`).
pub fn parse(input: &str, loc: &Locale) -> Option<Result<FileCommand, String>> {
let mut tokens = input.split_whitespace();
let first = tokens.next()?;
if !first.eq_ignore_ascii_case("/file") {
return None;
}
let Some(sub) = tokens.next() else {
return Some(Err(loc.tf(
"ui.file.err.missing_subcommand",
&[("usage", loc.t("ui.file.usage"))],
)));
};
let rest: Vec<&str> = tokens.collect();
if sub.eq_ignore_ascii_case("attach") {
match argument(&rest) {
Some(path) => Some(Ok(FileCommand::Attach { path })),
None => Some(Err(loc.tf(
"ui.file.err.missing_path",
&[("usage", loc.t("ui.file.usage"))],
))),
}
} else if sub.eq_ignore_ascii_case("remove") {
// Only `remove`, never `delete` — the same wording decision RAG made
// after users worried a command would delete the file from disk.
match argument(&rest) {
Some(target) => Some(Ok(FileCommand::Remove { target })),
None => Some(Err(loc.tf(
"ui.file.err.missing_target",
&[("usage", loc.t("ui.file.usage"))],
))),
}
} else if sub.eq_ignore_ascii_case("list") {
Some(Ok(FileCommand::List))
} else if sub.eq_ignore_ascii_case("open") {
// An argument's absence must not quietly change the target into the folder
// (fork F9): `/file folder` is the command for that.
match argument(&rest) {
Some(target) => Some(Ok(FileCommand::Open { target })),
None => Some(Err(loc.tf(
"ui.file.err.missing_target",
&[("usage", loc.t("ui.file.usage"))],
))),
}
} else if sub.eq_ignore_ascii_case("folder") {
Some(Ok(FileCommand::Folder))
} else {
Some(Err(loc.tf(
"ui.file.err.unknown_subcommand",
&[("sub", sub), ("usage", loc.t("ui.file.usage"))],
)))
}
}
/// Joins the tokens after the subcommand into one argument: a path or name may
/// contain spaces, and surrounding quotes are stripped. `None` — nothing left.
fn argument(tokens: &[&str]) -> Option<String> {
let joined = tokens.join(" ");
let arg = joined.trim().trim_matches(|c| c == '"' || c == '\'').trim();
(!arg.is_empty()).then(|| arg.to_string())
}
/// A short human label for the attachment's mode (localized).
pub fn mode_label(mode: AttachMode, loc: &'static Locale) -> &'static str {
match mode {
AttachMode::Inline => loc.t("ui.file.mode.inline"),
AttachMode::ByReference => loc.t("ui.file.mode.by_reference"),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A reference locale (ru) for parse tests — the exact wording isn't
/// asserted here (see `errors_are_localized_for_all_langs`), only the
/// Ok/Err shape.
fn ru() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
fn attach(path: &str) -> Option<Result<FileCommand, String>> {
Some(Ok(FileCommand::Attach { path: path.into() }))
}
#[test]
fn non_file_input_is_none() {
assert_eq!(parse("regular message", ru()), None);
assert_eq!(parse("/rag add x", ru()), None);
assert_eq!(parse("", ru()), None);
}
#[test]
fn parses_attach_with_path() {
assert_eq!(
parse("/file attach d:\\dir\\notes.md", ru()),
attach("d:\\dir\\notes.md")
);
}
#[test]
fn path_with_spaces_and_quotes() {
assert_eq!(
parse("/file attach C:\\Program Files\\a.txt", ru()),
attach("C:\\Program Files\\a.txt")
);
assert_eq!(
parse("/file attach \"d:\\my docs\\a.txt\"", ru()),
attach("d:\\my docs\\a.txt")
);
}
#[test]
fn command_is_case_insensitive() {
assert_eq!(parse("/FILE ATTACH d:\\x.txt", ru()), attach("d:\\x.txt"));
assert_eq!(parse("/File List", ru()), Some(Ok(FileCommand::List)));
}
#[test]
fn parses_remove_and_list() {
assert_eq!(
parse("/file remove notes.md", ru()),
Some(Ok(FileCommand::Remove {
target: "notes.md".into()
}))
);
assert_eq!(
parse("/file remove #2", ru()),
Some(Ok(FileCommand::Remove {
target: "#2".into()
}))
);
assert_eq!(parse("/file list", ru()), Some(Ok(FileCommand::List)));
// Extra tokens after `list` are ignored (it takes no argument).
assert_eq!(
parse("/file list everything", ru()),
Some(Ok(FileCommand::List))
);
}
#[test]
fn parses_open_and_folder() {
assert_eq!(
parse("/file open #3", ru()),
Some(Ok(FileCommand::Open {
target: "#3".into()
}))
);
assert_eq!(
parse("/FILE Open \"my chart.png\"", ru()),
Some(Ok(FileCommand::Open {
target: "my chart.png".into()
}))
);
assert_eq!(parse("/file folder", ru()), Some(Ok(FileCommand::Folder)));
// `open` with nothing to open is an error, not the folder (fork F9).
assert!(matches!(parse("/file open", ru()), Some(Err(_))));
}
#[test]
fn delete_is_not_a_command() {
// `delete` is deliberately unsupported (see the parser's comment).
assert!(matches!(parse("/file delete a.txt", ru()), Some(Err(_))));
}
#[test]
fn errors_on_missing_parts() {
assert!(matches!(parse("/file", ru()), Some(Err(_))));
assert!(matches!(parse("/file attach", ru()), Some(Err(_))));
assert!(matches!(parse("/file remove", ru()), Some(Err(_))));
assert!(matches!(parse("/file purge x", ru()), Some(Err(_))));
}
/// Per-locale coverage (i18n gate discipline, docs/history/i18n-ui.md §3.5):
/// every error path renders under EVERY built-in language with no
/// unsubstituted `{…}` and, for `en`, with no Cyrillic leaking through from
/// the ru default.
#[test]
fn errors_are_localized_for_all_langs() {
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
for input in [
"/file",
"/file attach",
"/file remove",
"/file open",
"/file purge x",
] {
let Some(Err(msg)) = parse(input, loc) else {
panic!("expected a syntax error for {input:?} in {lang:?}");
};
assert!(
!msg.contains('{') && !msg.contains('}'),
"unsubstituted placeholder in {lang:?}: {msg}"
);
if lang == crate::shared::i18n::Lang::En {
assert!(
!msg.chars().any(|c| ('\u{0400}'..='\u{04FF}').contains(&c)),
"Cyrillic leaked into the en message: {msg}"
);
}
}
// Mode labels are localized too.
for mode in [AttachMode::Inline, AttachMode::ByReference] {
assert!(!mode_label(mode, loc).is_empty());
}
}
}
}