Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88b6bba9f2 |
@@ -140,6 +140,63 @@ pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
|
|||||||
cur
|
cur
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Command wrappers that run another program: the program is what follows.
|
||||||
|
const WRAPPERS: [&str; 8] = [
|
||||||
|
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The program a command line runs, basenamed and stripped of its arguments —
|
||||||
|
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
|
||||||
|
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
|
||||||
|
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
|
||||||
|
pub fn command_program(command: &str) -> String {
|
||||||
|
let words: Vec<&str> = command.split_whitespace().collect();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < words.len() {
|
||||||
|
let word = words[i];
|
||||||
|
if is_env_assignment(word) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let wrapper = basename(word).to_lowercase();
|
||||||
|
if WRAPPERS.contains(&wrapper.as_str()) {
|
||||||
|
i += 1;
|
||||||
|
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match words.get(i) {
|
||||||
|
Some(word) => basename(word).to_string(),
|
||||||
|
// Nothing but wrappers and assignments: say what was there rather than
|
||||||
|
// rendering an empty fragment.
|
||||||
|
None => command.trim().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `FOO=bar`, the shell's own prefix for one command's environment.
|
||||||
|
fn is_env_assignment(word: &str) -> bool {
|
||||||
|
let mut chars = word.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
let rest: String = chars.collect();
|
||||||
|
match rest.split_once('=') {
|
||||||
|
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn basename(word: &str) -> &str {
|
||||||
|
match word.rsplit_once('/') {
|
||||||
|
Some((_, tail)) => tail,
|
||||||
|
None => word,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -318,4 +375,28 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(nested.name, "a/b");
|
assert_eq!(nested.name, "a/b");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_program_drops_arguments() {
|
||||||
|
assert_eq!(
|
||||||
|
command_program("claude --dangerously-skip-permissions"),
|
||||||
|
"claude"
|
||||||
|
);
|
||||||
|
assert_eq!(command_program(" zsh -l "), "zsh");
|
||||||
|
assert_eq!(command_program("hx src/main.rs"), "hx");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_program_looks_through_paths_env_and_wrappers() {
|
||||||
|
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
|
||||||
|
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
|
||||||
|
assert_eq!(command_program("npx opencode"), "opencode");
|
||||||
|
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_program_keeps_a_line_that_is_only_wrappers() {
|
||||||
|
assert_eq!(command_program("sudo"), "sudo");
|
||||||
|
assert_eq!(command_program(""), "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ use std::collections::{BTreeMap, HashSet};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path};
|
use maestro_protocol::display::{
|
||||||
|
abbreviate_dir, abbreviate_folder_name, command_program, session_tree_path,
|
||||||
|
};
|
||||||
use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
|
use maestro_protocol::{ActivityState, Session, SessionStatus, StatKind};
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
@@ -297,7 +299,8 @@ impl Sidebar {
|
|||||||
if dead {
|
if dead {
|
||||||
tail.push(Span::styled(" exited", muted));
|
tail.push(Span::styled(" exited", muted));
|
||||||
}
|
}
|
||||||
let command = Span::styled(format!(" {}", s.command), muted);
|
// docs/SIDEBAR.md: the card names the program, never its arguments.
|
||||||
|
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
|
||||||
|
|
||||||
// Reserve the triggered status fragments before detail and command.
|
// Reserve the triggered status fragments before detail and command.
|
||||||
// If the row is exceptionally narrow, keep one column for the
|
// If the row is exceptionally narrow, keep one column for the
|
||||||
@@ -874,12 +877,34 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
|
||||||
|
#[test]
|
||||||
|
fn meta_line_drops_the_commands_arguments() {
|
||||||
|
let mut s = session_named(1, "handle");
|
||||||
|
s.command = "claude --dangerously-skip-permissions".into();
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 60, 6));
|
||||||
|
let meta = content(&all[3]);
|
||||||
|
assert!(
|
||||||
|
meta.contains("claude"),
|
||||||
|
"line 2 must name the program: {meta:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!meta.contains("dangerously"),
|
||||||
|
"line 2 must not carry arguments: {meta:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The sidebar is narrow: the command truncates with `…` and never wraps
|
/// The sidebar is narrow: the command truncates with `…` and never wraps
|
||||||
/// onto a third row (docs/SIDEBAR.md).
|
/// onto a third row (docs/SIDEBAR.md).
|
||||||
#[test]
|
#[test]
|
||||||
fn meta_line_command_truncates_instead_of_wrapping() {
|
fn meta_line_command_truncates_instead_of_wrapping() {
|
||||||
let mut s = session_named(1, "claude");
|
let mut s = session_named(1, "claude");
|
||||||
s.command = "a command that cannot fit".into();
|
s.command = "a-program-name-that-cannot-fit".into();
|
||||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||||
s.blocked_work = Some(InTheWay {
|
s.blocked_work = Some(InTheWay {
|
||||||
uncommitted_files: 120,
|
uncommitted_files: 120,
|
||||||
@@ -1099,7 +1124,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn blocked_warning_survives_before_a_truncated_command() {
|
fn blocked_warning_survives_before_a_truncated_command() {
|
||||||
let mut s = session_named(1, "name");
|
let mut s = session_named(1, "name");
|
||||||
s.command = "a command that cannot fit".into();
|
s.command = "a-program-name-that-cannot-fit".into();
|
||||||
s.blocked_work = Some(InTheWay {
|
s.blocked_work = Some(InTheWay {
|
||||||
uncommitted_files: 1,
|
uncommitted_files: 1,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
+19
-14
@@ -88,21 +88,26 @@ of `maestro_protocol::InTheWay`'s `Display` does not disappear: it is the web
|
|||||||
card's tooltip and the wording of the kill refusal, so no two clients describe
|
card's tooltip and the wording of the kill refusal, so no two clients describe
|
||||||
the same tree differently.
|
the same tree differently.
|
||||||
|
|
||||||
`<command>` is unconditional and muted: the session's command line, shown on
|
`<command>` is unconditional and muted: it names the **program** the session
|
||||||
every card in both clients — no omission in the TUI, no name-vs-command
|
runs — never its arguments. `claude --dangerously-skip-permissions` renders as
|
||||||
comparison gating it. A session named by its command line then shows the same
|
`claude`. Flags say how a session was invoked, which is not what a card is for,
|
||||||
string on both lines; that is accepted, the price of a row that never changes
|
and a long one crowds out every other fragment on the line. It shows on every
|
||||||
height.
|
card in both clients — no omission in the TUI, no name-vs-command comparison
|
||||||
|
gating it. A session named by its program then shows the same string on both
|
||||||
|
lines; that is accepted, the price of a row that never changes height.
|
||||||
|
|
||||||
In the **web client** the command's leading program is replaced by that tool's
|
The program is found by looking through a path, leading `FOO=bar` assignments
|
||||||
icon when it is one we have a mark for — Claude Code, Codex, opencode, Gemini
|
and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …), then taking the basename:
|
||||||
CLI, Aider, Cursor, pi, and any shell — and only the remaining words of the
|
`/usr/local/bin/claude -p hi` is `claude`. A line that is nothing but wrappers
|
||||||
command line follow as text. Recognition looks through a path, leading
|
is shown as written rather than as an empty fragment.
|
||||||
`FOO=bar` assignments and wrappers (`sudo`, `env`, `npx`, `pnpm dlx`, …); an
|
|
||||||
unrecognised program keeps its full text, so the card never shows a mark
|
In the **web client** the program is replaced by that tool's icon when it is
|
||||||
standing for the wrong tool. The full command line stays in the card's tooltip
|
one we have a mark for — Claude Code, Codex, opencode, Gemini CLI, Aider,
|
||||||
either way. The **TUI** keeps the command as text: a terminal cannot be assumed
|
Cursor, pi, and any shell — and nothing follows it as text. An unrecognised
|
||||||
to have a font carrying tool glyphs.
|
program keeps its name as text, so the card never shows a mark standing for the
|
||||||
|
wrong tool. The full command line, arguments and all, stays in the card's
|
||||||
|
tooltip either way. The **TUI** keeps the program as text: a terminal cannot be
|
||||||
|
assumed to have a font carrying tool glyphs.
|
||||||
|
|
||||||
## Colour per activity state
|
## Colour per activity state
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -56,12 +56,13 @@ export interface AttachInfo {
|
|||||||
command: string;
|
command: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Header echoes the sidebar: the command's leading program becomes its tool
|
/** Header echoes the sidebar: the program becomes its tool icon when we have a
|
||||||
* icon when we have a mark for it, remaining words stay as text. */
|
* mark for it and stays as its bare name when we do not. Arguments are
|
||||||
|
* dropped, as on the card. */
|
||||||
function commandFragment(command: string): string {
|
function commandFragment(command: string): string {
|
||||||
const { tool, rest } = splitCommand(command);
|
const { tool, program } = splitCommand(command);
|
||||||
const icon = tool ? toolIconSvg(tool) : null;
|
const icon = tool ? toolIconSvg(tool) : null;
|
||||||
const text = icon ? rest : command;
|
const text = icon ? "" : program;
|
||||||
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
|
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,15 +209,16 @@ describe("session cards (#107)", () => {
|
|||||||
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the command's arguments as text beside the icon", () => {
|
it("drops the command's arguments, keeping them in the tooltip", () => {
|
||||||
const s = session({
|
const s = session({
|
||||||
id: "aaaaaaaa-0000-0000-0000-00000000001c",
|
id: "aaaaaaaa-0000-0000-0000-00000000001c",
|
||||||
command: "claude --resume",
|
command: "claude --dangerously-skip-permissions",
|
||||||
});
|
});
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||||
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
|
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
|
||||||
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("--resume");
|
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
|
||||||
|
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps an unknown command as plain text", () => {
|
it("keeps an unknown command as plain text", () => {
|
||||||
@@ -228,7 +229,8 @@ describe("session cards (#107)", () => {
|
|||||||
const el = renderSidebar(storeWith([s])).el;
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
|
||||||
expect(cmd?.querySelector(".tool-icon")).toBeNull();
|
expect(cmd?.querySelector(".tool-icon")).toBeNull();
|
||||||
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx src/main.rs");
|
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
|
||||||
|
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps each activity state to its contract slug", () => {
|
it("maps each activity state to its contract slug", () => {
|
||||||
|
|||||||
+6
-5
@@ -96,13 +96,14 @@ function blockedFragment(blockers: InTheWay): string {
|
|||||||
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
|
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: the command's leading program becomes its tool icon when
|
/** docs/SIDEBAR.md: the card names the program only — arguments never reach
|
||||||
* we have a mark for it, and only the remaining words stay as text. The full
|
* the card. The program becomes its tool icon when we have a mark for it, and
|
||||||
* command line stays in the tooltip either way. */
|
* stays as its bare name when we do not. The full command line, arguments and
|
||||||
|
* all, stays in the tooltip either way. */
|
||||||
function commandFragment(command: string): string {
|
function commandFragment(command: string): string {
|
||||||
const { tool, rest } = splitCommand(command);
|
const { tool, program } = splitCommand(command);
|
||||||
const icon = tool ? toolIconSvg(tool) : null;
|
const icon = tool ? toolIconSvg(tool) : null;
|
||||||
const text = icon ? rest : command;
|
const text = icon ? "" : program;
|
||||||
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
|
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps everything after the program as text", () => {
|
it("drops everything after the program", () => {
|
||||||
expect(splitCommand("claude --resume -p hi")).toEqual({
|
expect(splitCommand("claude --dangerously-skip-permissions")).toEqual({
|
||||||
tool: "claude",
|
tool: "claude",
|
||||||
rest: "--resume -p hi",
|
program: "claude",
|
||||||
});
|
});
|
||||||
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", rest: "-l" });
|
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", program: "zsh" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("looks through paths, env assignments and wrappers", () => {
|
it("looks through paths, env assignments and wrappers", () => {
|
||||||
@@ -33,13 +33,13 @@ describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
|
|||||||
expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude");
|
expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude");
|
||||||
expect(splitCommand("npx opencode").tool).toBe("opencode");
|
expect(splitCommand("npx opencode").tool).toBe("opencode");
|
||||||
expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode");
|
expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode");
|
||||||
expect(splitCommand("pnpm dlx opencode run").rest).toBe("run");
|
expect(splitCommand("pnpm dlx opencode run").program).toBe("opencode");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves an unrecognised program as text, mark-less", () => {
|
it("leaves an unrecognised program as its bare name, mark-less", () => {
|
||||||
expect(splitCommand("hx src/main.rs")).toEqual({
|
expect(splitCommand("hx src/main.rs")).toEqual({
|
||||||
tool: null,
|
tool: null,
|
||||||
rest: "hx src/main.rs",
|
program: "hx",
|
||||||
});
|
});
|
||||||
// A wrapper with nothing after it is not a tool either.
|
// A wrapper with nothing after it is not a tool either.
|
||||||
expect(splitCommand("sudo").tool).toBeNull();
|
expect(splitCommand("sudo").tool).toBeNull();
|
||||||
|
|||||||
+17
-18
@@ -2,11 +2,11 @@
|
|||||||
*
|
*
|
||||||
* A session's command line starts with the program that runs it, and that
|
* A session's command line starts with the program that runs it, and that
|
||||||
* program is nearly always one of a handful of agents or a shell. Rendering it
|
* program is nearly always one of a handful of agents or a shell. Rendering it
|
||||||
* as its own mark buys back most of the fragment's width, which the sidebar
|
* as its own mark buys back the whole fragment's width — the arguments are
|
||||||
* spends on the rest of the command line instead.
|
* dropped, so a card says which tool runs, not how it was invoked.
|
||||||
*
|
*
|
||||||
* Recognition is deliberately narrow: an unrecognised program keeps its text,
|
* Recognition is deliberately narrow: an unrecognised program keeps its name
|
||||||
* so the fragment never renders a mark that stands for the wrong tool.
|
* as text, so the fragment never renders a mark that stands for the wrong tool.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Command wrappers that run another program: the tool is what follows. */
|
/** Command wrappers that run another program: the tool is what follows. */
|
||||||
@@ -48,15 +48,15 @@ export const TOOL_LABELS: Record<string, string> = {
|
|||||||
export interface CommandParts {
|
export interface CommandParts {
|
||||||
/** Icon id, or `null` when the program is not one we have a mark for. */
|
/** Icon id, or `null` when the program is not one we have a mark for. */
|
||||||
tool: string | null;
|
tool: string | null;
|
||||||
/** What is left of the command line once the icon stands for the program.
|
/** The program the command runs, basenamed and stripped of its arguments —
|
||||||
* Empty when the icon says everything the command line said. */
|
* what a card shows when there is no icon to show instead. */
|
||||||
rest: string;
|
program: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Split a command line into the tool its icon stands for and the remainder.
|
/** Split a command line into the tool its icon stands for and the program name.
|
||||||
*
|
*
|
||||||
* `claude --resume` → `{tool: "claude", rest: "--resume"}`; an unrecognised
|
* `claude --resume` → `{tool: "claude", program: "claude"}`; an unrecognised
|
||||||
* `hx src/` keeps the whole line as `rest` with no tool. */
|
* `hx src/` keeps `hx` as the program with no tool. */
|
||||||
export function splitCommand(command: string): CommandParts {
|
export function splitCommand(command: string): CommandParts {
|
||||||
const words = command.trim().split(/\s+/).filter(Boolean);
|
const words = command.trim().split(/\s+/).filter(Boolean);
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -68,23 +68,22 @@ export function splitCommand(command: string): CommandParts {
|
|||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const program = basename(word);
|
const wrapper = basename(word).toLowerCase();
|
||||||
if (WRAPPERS.has(program)) {
|
if (WRAPPERS.has(wrapper)) {
|
||||||
i += 1;
|
i += 1;
|
||||||
if ((program === "pnpm" || program === "yarn") && words[i] === "dlx") i += 1;
|
if ((wrapper === "pnpm" || wrapper === "yarn") && words[i] === "dlx") i += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (i >= words.length) return { tool: null, rest: command.trim() };
|
if (i >= words.length) return { tool: null, program: command.trim() };
|
||||||
const tool = BY_PROGRAM[basename(words[i])] ?? null;
|
const program = basename(words[i]);
|
||||||
if (!tool) return { tool: null, rest: command.trim() };
|
return { tool: BY_PROGRAM[program.toLowerCase()] ?? null, program };
|
||||||
return { tool, rest: words.slice(i + 1).join(" ") };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function basename(word: string): string {
|
function basename(word: string): string {
|
||||||
const cut = word.lastIndexOf("/");
|
const cut = word.lastIndexOf("/");
|
||||||
return (cut >= 0 ? word.slice(cut + 1) : word).toLowerCase();
|
return cut >= 0 ? word.slice(cut + 1) : word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
|
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
|
||||||
|
|||||||
Reference in New Issue
Block a user