feat(agents): reference folders from @ completion (#1210)

Folders now appear in the composer's `@` completion next to files, from a new
`find_dirs` index query over the folders that hold indexed files (kept
incrementally in the index, ~0.5 ms on 100k files). Enter or a click
references the folder as `@dir/` and sends it as a resource link; Tab keeps
completing inside it.

Closes #1202
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-04 20:44:14 +08:00
1 джерело d7886bab45
коміт b473876d59
19 files changed
+331 -24

No files matched your search

+5
Переглянути файл
@@ -36,6 +36,10 @@ pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeRe
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_dirs(&query, limit as usize))
}
pub fn find_symbols(
host: &Host,
window: &str,
@@ -157,6 +161,7 @@ pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) ->
pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
});
+1
Переглянути файл
@@ -226,6 +226,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "deploy_coverage"
| "deploy_test_connection"
| "find_files"
| "find_dirs"
| "find_symbols"
| "mark_recent"
| "remove_recent"
+3
Переглянути файл
@@ -14,6 +14,9 @@ fn bench(c: &mut Criterion) {
for q in ["ordcon", "src/user", "PaymentService", "zzzz", "a"] {
g.bench_function(format!("find_files/{q}"), |b| b.iter(|| black_box(index.find_files(q, 50).len())));
}
for q in ["util", "src/ui", "a"] {
g.bench_function(format!("find_dirs/{q}"), |b| b.iter(|| black_box(index.find_dirs(q, 50).len())));
}
for q in ["OCo", "invoiceRepo", "renderView", "q"] {
g.bench_function(format!("find_symbols/{q}"), |b| {
b.iter(|| black_box(index.find_symbols(q, SymbolFilter::All, 50).len()))
+47
Переглянути файл
@@ -134,6 +134,9 @@ impl FileEntry {
pub(crate) struct IndexData {
pub files: Vec<Option<FileEntry>>,
by_path: HashMap<PathBuf, u32>,
/// Every folder holding an indexed file at any depth, keyed by its `rel` with a
/// trailing `/`: its absolute path and the number of files below it.
pub dirs: HashMap<Box<str>, (PathBuf, u32)>,
free: Vec<u32>,
pub file_count: u32,
pub symbol_count: u32,
@@ -167,6 +170,11 @@ impl IndexData {
}
};
let path = self.files[slot as usize].as_ref().map(|e| e.path.clone()).unwrap_or_default();
if let Some(e) = &self.files[slot as usize] {
for (rel, abs) in ancestors(e) {
self.dirs.entry(rel.into()).or_insert_with(|| (abs.to_owned(), 0)).1 += 1;
}
}
self.by_path.insert(path, slot);
self.file_count += 1;
self.symbol_count += symbols;
@@ -176,6 +184,14 @@ impl IndexData {
let slot = self.by_path.remove(path)?;
let old = self.files[slot as usize].take()?;
self.free.push(slot);
for (rel, _) in ancestors(&old) {
if let Some(d) = self.dirs.get_mut(rel) {
d.1 -= 1;
if d.1 == 0 {
self.dirs.remove(rel);
}
}
}
self.file_count -= 1;
self.symbol_count -= old.symbols.len() as u32;
Some(old)
@@ -197,6 +213,7 @@ impl IndexData {
self.files.shrink_to_fit();
self.free.shrink_to_fit();
self.by_path.shrink_to_fit();
self.dirs.shrink_to_fit();
}
pub(crate) fn paths(&self) -> impl Iterator<Item = &PathBuf> {
@@ -204,6 +221,19 @@ impl IndexData {
}
}
/// The folders holding `e` (`rel` with a trailing `/`, absolute path), innermost first.
fn ancestors(e: &FileEntry) -> impl Iterator<Item = (&str, &Path)> {
let mut rel = e.dir();
let mut abs = e.path.parent();
std::iter::from_fn(move || {
let path = abs.filter(|_| !rel.is_empty())?;
let here = rel;
rel = rel[..rel.len() - 1].rfind('/').map_or("", |i| &rel[..=i]);
abs = path.parent();
Some((here, path))
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -223,6 +253,23 @@ mod tests {
FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record)
}
#[test]
fn dirs_follow_inserts_and_removals() {
let mut d = IndexData::default();
d.insert(entry("a/b/c.rs", 0));
d.insert(entry("a/d.rs", 0));
d.insert(entry("a/d.rs", 0));
d.insert(entry("top.rs", 0));
let mut dirs: Vec<_> = d.dirs.iter().map(|(k, v)| (k.to_string(), v.1)).collect();
dirs.sort();
assert_eq!(dirs, [("a/".into(), 2), ("a/b/".into(), 1)]);
assert_eq!(d.dirs["a/b/"].0, PathBuf::from("/r/a/b"));
d.remove(Path::new("/r/a/b/c.rs"));
assert!(!d.dirs.contains_key("a/b/"));
d.remove_tree(Path::new("/r/a"));
assert!(d.dirs.is_empty());
}
#[test]
fn insert_replace_remove_keep_counts() {
let mut d = IndexData::default();
+9
Переглянути файл
@@ -167,6 +167,11 @@ impl ProjectIndex {
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
}
/// Fuzzy search for folders that hold indexed files; `rel_path` ends in `/`.
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
matching::find_dirs(&self.shared.data.read(), query, limit)
}
/// Fuzzy symbol search; CamelHump aware (`OCo` → `OrderController`).
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
let recent = self.shared.recent.lock().clone();
@@ -366,6 +371,10 @@ pub mod bench_support {
matching::find_files(&self.data, &matching::Recents { paths: &self.recent }, query, limit)
}
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
matching::find_dirs(&self.data, query, limit)
}
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
matching::find_symbols(&self.data, &matching::Recents { paths: &self.recent }, query, filter, limit)
}
+71 -1
Переглянути файл
@@ -12,7 +12,7 @@ use nucleo_matcher::{Config, Matcher, Utf32Str, Utf32String};
use rayon::prelude::*;
use std::cell::RefCell;
use std::cmp::Ordering;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
const TIER_NAME: u32 = 1 << 20;
const EXACT: u32 = 4000;
@@ -251,6 +251,76 @@ pub(crate) fn find_files(data: &IndexData, recents: &Recents, query: &str, limit
.collect()
}
/// Fuzzy folder search over the folders holding indexed files: folder-name matches beat
/// path matches; `/` in the query matches the whole path. `rel_path` ends in `/`.
pub(crate) fn find_dirs(data: &IndexData, query: &str, limit: usize) -> Vec<FileMatch> {
let q = normalize_file_query(query);
if limit == 0 || q.is_empty() {
return Vec::new();
}
let by_path = q.contains('/');
let needle = Needle::new(q.trim_end_matches('/'));
// (score, rel without the trailing `/`, char offset of the name when it matched by name)
let score = |m: &mut Matcher, buf: &mut Vec<char>, rel: &str| -> Option<(u32, Option<u32>)> {
let trimmed = &rel[..rel.len() - 1];
let start = trimmed.rfind('/').map_or(0, |i| i + 1);
let name = &trimmed[start..];
if !by_path
&& needle.mask & char_mask(name) == needle.mask
&& let Some(s) = needle.score(m, name, buf)
{
return Some((TIER_NAME + s * 4 + needle.name_boost(name), Some(trimmed[..start].chars().count() as u32)));
}
if needle.mask & char_mask(trimmed) != needle.mask {
return None;
}
needle.score(m, trimmed, buf).map(|s| (s, None))
};
let mut best: Vec<(u32, &str, &Path, Option<u32>)> = data
.dirs
.par_iter()
.filter_map(|(rel, (path, _))| {
let mut buf = Vec::new();
with_matcher(|m| score(m, &mut buf, rel)).map(|(s, at)| (s, &**rel, path.as_path(), at))
})
.collect();
let order = |a: &(u32, &str, &Path, Option<u32>), b: &(u32, &str, &Path, Option<u32>)| {
b.0.cmp(&a.0).then_with(|| a.1.len().cmp(&b.1.len())).then_with(|| a.1.cmp(b.1))
};
if best.len() > limit {
best.select_nth_unstable_by(limit, order);
best.truncate(limit);
}
// Match indices and the CamelHump bonus `find_files` gives, so equal matches rank alike.
let mut out: Vec<FileMatch> = with_matcher(|m| {
let (mut buf, mut idx) = (Vec::new(), Vec::new());
best.into_iter()
.map(|(score, rel, path, name_at)| {
let trimmed = &rel[..rel.len() - 1];
let (bonus, indices) = match name_at {
Some(at) => {
let name = &trimmed[trimmed.rfind('/').map_or(0, |i| i + 1)..];
needle.indices(m, name, &mut buf, &mut idx);
(hump_bonus(name, &idx), idx.iter().map(|i| i + at).collect())
}
None => {
needle.indices(m, trimmed, &mut buf, &mut idx);
(hump_bonus(trimmed, &idx), idx.clone())
}
};
FileMatch { path: path.to_path_buf().into(), rel_path: rel.to_owned(), score: score + bonus, indices }
})
.collect()
});
out.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| a.rel_path.len().cmp(&b.rel_path.len()))
.then_with(|| a.rel_path.cmp(&b.rel_path))
});
out
}
fn file(data: &IndexData, slot: u32) -> &FileEntry {
data.files[slot as usize].as_ref().expect("candidate slots are live")
}
+17
Переглянути файл
@@ -138,6 +138,23 @@ fn file_ranking_is_idea_like() {
assert_eq!(shown.to_lowercase(), "uibtn");
}
#[test]
fn find_dirs_ranks_folder_names() {
let f = sample();
let idx = f.open();
let dirs = |q: &str| idx.find_dirs(q, 20).into_iter().map(|m| m.rel_path).collect::<Vec<_>>();
assert_eq!(dirs("order"), ["src/order/"]);
assert_eq!(dirs("src")[0], "src/");
assert!(dirs("src").contains(&"src/ui/".to_owned()), "{:?}", dirs("src"));
assert_eq!(dirs("src/ui/"), ["src/ui/"]);
assert!(dirs("").is_empty());
assert!(dirs("main").is_empty());
let m = &idx.find_dirs("ui", 1)[0];
assert_eq!(m.path.0, f.root.join("src/ui"));
assert_eq!(m.indices, vec![4, 5]);
}
#[test]
fn shorter_paths_win_ties_and_recent_files_boost() {
let fx = Fixture::new(&[("a/b/c/util.rs", ""), ("x/util.rs", ""), ("y/util.rs", "")]);
+3
Переглянути файл
@@ -76,6 +76,9 @@
"index_100k/find_files/ordcon": 10000,
"index_100k/find_files/src/user": 10000,
"index_100k/find_files/PaymentService": 10000,
"index_100k/find_dirs/util": 5000,
"index_100k/find_dirs/src/ui": 5000,
"index_100k/find_dirs/a": 5000,
"index_100k/find_symbols/OCo": 15000,
"index_100k/find_symbols/q": 15000,
"index_100k/find_symbols/invoiceRepo": 15000,
+1
Переглянути файл
@@ -188,6 +188,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
nav::index_status,
nav::spring_route_sources,
nav::find_files,
nav::find_dirs,
nav::find_symbols,
nav::mark_recent,
nav::recent_files,
+7
Переглянути файл
@@ -22,6 +22,13 @@ pub fn find_files(window: Window, state: State<'_, AppState>, query: String, lim
ide_host::ops::nav::find_files(&state.host, window.label(), query, limit)
}
/// Folders holding indexed files, for `@` references.
#[tauri::command]
#[specta::specta]
pub fn find_dirs(window: Window, state: State<'_, AppState>, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
ide_host::ops::nav::find_dirs(&state.host, window.label(), query, limit)
}
/// Spring Web mapping sources for the request-path index of Search Everywhere.
#[tauri::command]
#[specta::specta]
+2
Переглянути файл
@@ -327,6 +327,8 @@ export const commands = {
/** Spring Web mapping sources for the request-path index of Search Everywhere. */
springRouteSources: () => typedError<RouteSource[], IdeError>(__TAURI_INVOKE("spring_route_sources")),
findFiles: (query: string, limit: number) => typedError<FileMatch[], IdeError>(__TAURI_INVOKE("find_files", { query, limit })),
/** Folders holding indexed files, for `@` references. */
findDirs: (query: string, limit: number) => typedError<FileMatch[], IdeError>(__TAURI_INVOKE("find_dirs", { query, limit })),
findSymbols: (query: string, filter: SymbolFilter, limit: number) => typedError<SymbolMatch[], IdeError>(__TAURI_INVOKE("find_symbols", { query, filter, limit })),
markRecent: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("mark_recent", { path })),
/** Recently opened files, most recent first (⌘E). */
+25 -19
Переглянути файл
@@ -1,6 +1,6 @@
/**
* The message box of the New Task page and of sessions: `@` file references (project file
* search), `/` agent commands, ↑ / ↓ history, pasted or dropped images, Shift+Tab to cycle
* The message box of the New Task page and of sessions: `@` file and folder references
* (project index search), `/` agent commands, ↑ / ↓ history, pasted or dropped images, Shift+Tab to cycle
* the agent's mode. Its draft lives in the store under `id`.
*/
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
@@ -19,6 +19,7 @@ import {
applyCompletion,
atEdgeLine,
isPathQuery,
mergeMatches,
resolveTyped,
completionAt,
imageFromDataUrl,
@@ -129,14 +130,15 @@ export function Composer(props: ComposerProps) {
.catch(() => alive && setFiles([]));
return;
}
call(commands.findFiles(query, MAX_ITEMS))
.then((found) => {
Promise.all([call(commands.findFiles(query, MAX_ITEMS)), call(commands.findDirs(query, MAX_ITEMS))])
.then(([found, dirs]) => {
if (!alive) return;
setFiles(
found.map((f) => ({
mergeMatches(found, dirs, MAX_ITEMS).map((f) => ({
kind: "file",
rel: f.relPath,
path: root === projectRoot ? f.path : `${root}/${f.relPath}`,
rel: f.rel,
dir: f.dir,
path: root === projectRoot ? f.path : `${root}/${f.rel}`,
})),
);
})
@@ -171,19 +173,20 @@ export function Composer(props: ComposerProps) {
}
};
const accept = (item: Item | undefined) => {
/** Enter or a click references the item; Tab on a folder keeps completing inside it. */
const accept = (item: Item | undefined, descend = false) => {
if (!item || !completion) return;
if (item.kind === "file" && item.dir) {
// Descend: keep completing inside the folder.
const insert = `${mentionOf(item.rel)}/`;
setText(text.slice(0, completion.from) + insert + text.slice(completion.to), completion.from + insert.length);
return;
}
if (item.kind === "file") {
const mention = mentionOf(item.rel);
const next = applyCompletion(text, completion, mention);
const rel = item.dir ? `${item.rel}/` : item.rel;
// A folder stays referenced while its `@folder/` is left as typed (`toPrompt`).
const current = composerDraft(id);
updateComposer(id, { files: [...current.files, { path: item.path, rel: item.rel } satisfies FileRef] });
updateComposer(id, { files: [...current.files, { path: item.path, rel } satisfies FileRef] });
if (item.dir && descend) {
const insert = mentionOf(rel);
setText(text.slice(0, completion.from) + insert + text.slice(completion.to), completion.from + insert.length);
return;
}
const next = applyCompletion(text, completion, mentionOf(rel));
setText(next.text, next.caret);
} else {
const next = applyCompletion(text, completion, `/${item.command.name}`);
@@ -266,7 +269,7 @@ export function Composer(props: ComposerProps) {
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
accept(items[active]);
accept(items[active], e.key === "Tab");
return;
}
if (e.key === "Escape") {
@@ -345,7 +348,10 @@ export function Composer(props: ComposerProps) {
{item.kind === "file" ? (
<>
<Icon name={item.dir ? "folder" : fileIconName(basename(item.rel), item.rel)} size={16} />
<span className={styles.itemName}>{basename(item.rel)}</span>
<span className={styles.itemName}>
{basename(item.rel)}
{item.dir && "/"}
</span>
<span className={styles.itemDetail}>{item.rel}</span>
</>
) : (
+3 -1
Переглянути файл
@@ -99,7 +99,9 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
id={HOME}
autoFocus
placeholder={
docked ? "Describe a task…" : "Describe a task for the agent. @ references a file, / runs a command"
docked
? "Describe a task…"
: "Describe a task for the agent. @ references a file or folder, / runs a command"
}
root={root}
projectRoot={root}
+27
Переглянути файл
@@ -15,6 +15,7 @@ import {
nextValue,
recall,
toPrompt,
mergeMatches,
retryParts,
usageLabel,
limitLabel,
@@ -67,6 +68,27 @@ const option = (category: string, values: string[], current: string | null): Age
via: "option",
});
describe("mergeMatches", () => {
it("ranks folders among files by score, files first on a tie", () => {
const merged = mergeMatches(
[
{ path: "/p/src/agents.ts", relPath: "src/agents.ts", score: 50 },
{ path: "/p/x.ts", relPath: "x.ts", score: 10 },
],
[
{ path: "/p/src/agents", relPath: "src/agents/", score: 50 },
{ path: "/p/docs", relPath: "docs/", score: 70 },
],
3,
);
expect(merged).toEqual([
{ path: "/p/docs", rel: "docs", dir: true, score: 70 },
{ path: "/p/src/agents.ts", rel: "src/agents.ts", dir: false, score: 50 },
{ path: "/p/src/agents", rel: "src/agents", dir: true, score: 50 },
]);
});
});
describe("agent composer", () => {
it("completes @files anywhere and /commands as the first word", () => {
expect(completionAt("fix @src/le", 11)).toEqual({ kind: "file", query: "src/le", from: 4, to: 11 });
@@ -111,6 +133,11 @@ describe("agent composer", () => {
});
expect(prompt).toEqual({ text: "look at @src/a.rs:3-5 and @b.rs", files: ["/w/src/a.rs", "/w/b.rs"], images: [] });
expect(mentionOf("a.rs", { from: 3, to: 3 })).toBe("@a.rs:3");
// A folder counts while `@src/` is left as typed, not once a file inside it was picked.
const folder = { path: "/w/src", rel: "src/" };
expect(toPrompt({ text: "explain @src/ please", files: [folder], images: [] }).files).toEqual(["/w/src"]);
expect(toPrompt({ text: "explain @src/", files: [folder], images: [] }).files).toEqual(["/w/src"]);
expect(toPrompt({ text: "explain @src/a.rs", files: [folder], images: [] }).files).toEqual([]);
expect(canSend({ text: " ", files: [], images: [] })).toBe(false);
});
+84
Переглянути файл
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const findFiles = vi.fn();
const findDirs = vi.fn();
const pathCompletions = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: vi.fn(() => false),
commands: {
...real.commands,
findFiles: (query: string, limit: number) => findFiles(query, limit),
findDirs: (query: string, limit: number) => findDirs(query, limit),
pathCompletions: (base: string, typed: string) => pathCompletions(base, typed),
},
};
});
import { Composer } from "./Composer";
import { composerDraft, updateComposer } from "./store";
import { emptyDraft, toPrompt } from "./composerModel";
const ID = "folders-test";
const ok = <T,>(data: T) => Promise.resolve({ status: "ok", data });
function renderComposer() {
render(
<Composer
id={ID}
placeholder="Ask"
root="/p"
projectRoot="/p"
commands={[]}
history={[]}
config={[]}
onSubmit={() => {}}
/>,
);
return screen.getByPlaceholderText("Ask") as HTMLTextAreaElement;
}
beforeEach(() => {
updateComposer(ID, emptyDraft());
const match = (path: string, relPath: string, score: number) => ({ path, relPath, score, indices: [] });
findFiles.mockReset().mockReturnValue(ok([match("/p/src/agents.ts", "src/agents.ts", 40)]));
findDirs.mockReset().mockReturnValue(ok([match("/p/src/agents", "src/agents/", 90)]));
pathCompletions.mockReset().mockReturnValue(ok([{ name: "lib", dir: true }]));
});
describe("Composer @ folders", () => {
it("lists index folders among files and references one on Enter", async () => {
const box = renderComposer();
await userEvent.type(box, "see @agen");
const options = await screen.findAllByRole("option");
expect(options.map((o) => o.textContent)).toEqual(["agents/src/agents", "agents.tssrc/agents.ts"]);
await userEvent.keyboard("{Enter}");
expect(box.value).toBe("see @src/agents/ ");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/agents"]);
});
it("keeps completing inside a folder on Tab, which drops the folder once a file is picked", async () => {
const box = renderComposer();
await userEvent.type(box, "@agen");
await screen.findAllByRole("option");
await userEvent.keyboard("{Tab}");
expect(box.value).toBe("@src/agents/");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/src/agents"]);
await userEvent.type(box, "x.ts ");
expect(toPrompt(composerDraft(ID)).files).toEqual([]);
});
it("references a folder of a typed path", async () => {
const box = renderComposer();
await userEvent.type(box, "@./");
await screen.findByRole("option");
await userEvent.keyboard("{Enter}");
expect(box.value).toBe("@./lib/ ");
expect(toPrompt(composerDraft(ID)).files).toEqual(["/p/lib"]);
});
});
+23
Переглянути файл
@@ -94,6 +94,29 @@ export function resolveTyped(root: string, rel: string): string {
return isDrive(out[0]) ? out.join("/") : `/${out.join("/")}`;
}
interface IndexMatch {
path: string;
relPath: string;
score: number;
}
/**
* Index file and folder matches as one list, best first (files first on a tie); folders'
* `rel` loses the trailing `/` the index gives them.
*/
export function mergeMatches(
files: readonly IndexMatch[],
dirs: readonly IndexMatch[],
limit: number,
): { path: string; rel: string; dir: boolean; score: number }[] {
return [
...files.map((f) => ({ path: f.path, rel: f.relPath, dir: false, score: f.score })),
...dirs.map((d) => ({ path: d.path, rel: d.relPath.replace(/\/$/, ""), dir: true, score: d.score })),
]
.sort((a, b) => b.score - a.score || Number(a.dir) - Number(b.dir))
.slice(0, limit);
}
/** `@rel` with a line range suffix when given ("@src/a.rs:10-20"). */
export function mentionOf(rel: string, lines?: { from: number; to: number }): string {
if (!lines) return `@${rel}`;
+1 -1
Переглянути файл
@@ -3385,7 +3385,7 @@
"Not installed": "Non installé",
"{command} was not found on PATH": "{command} est introuvable dans le PATH",
"Agent tasks": "Tâches d'agent",
"Describe a task for the agent. @ references a file, / runs a command": "Décrivez une tâche pour l'agent. @ référence un fichier, / lance une commande",
"Describe a task for the agent. @ references a file or folder, / runs a command": "Décrivez une tâche pour l'agent. @ référence un fichier ou un dossier, / lance une commande",
"away from your changes": "à l'écart de vos modifications",
"edits your files directly": "modifie directement vos fichiers",
"from": "depuis",
+1 -1
Переглянути файл
@@ -3492,7 +3492,7 @@
"Not installed": "설치되지 않음",
"{command} was not found on PATH": "PATH에서 {command}을(를) 찾을 수 없습니다",
"Agent tasks": "에이전트 작업",
"Describe a task for the agent. @ references a file, / runs a command": "에이전트에게 맡길 작업을 설명하세요. @는 파일 참조, /는 명령 실행",
"Describe a task for the agent. @ references a file or folder, / runs a command": "에이전트에게 맡길 작업을 설명하세요. @는 파일·폴더 참조, /는 명령 실행",
"away from your changes": "변경 사항과 분리됨",
"edits your files directly": "파일을 직접 편집함",
"from": "기준",
+1 -1
Переглянути файл
@@ -3498,7 +3498,7 @@
"Not installed": "未安装",
"{command} was not found on PATH": "在 PATH 中找不到 {command}",
"Agent tasks": "Agent 任务",
"Describe a task for the agent. @ references a file, / runs a command": "描述要交给 agent 的任务。@ 引用文件,/ 运行命令",
"Describe a task for the agent. @ references a file or folder, / runs a command": "描述要交给 agent 的任务。@ 引用文件或文件夹,/ 运行命令",
"away from your changes": "不影响你的改动",
"edits your files directly": "直接修改你的文件",
"from": "基于",