@saadnvd1/agent-os 0.3.756 → 0.3.758

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useRef, type ReactNode } from "react";
3
+ import { Fragment, useEffect, useRef, type ReactNode } from "react";
4
4
  import { RefreshCw } from "lucide-react";
5
5
  import { cn } from "@/lib/utils";
6
6
 
@@ -10,6 +10,25 @@ export interface MenuOption {
10
10
  icon?: ReactNode;
11
11
  hint?: string;
12
12
  description?: string;
13
+ // A section heading shown above this option when it starts a new section.
14
+ group?: string;
15
+ // A small label on the row (where a command comes from).
16
+ badge?: string;
17
+ }
18
+
19
+ // The part of `text` that matches what was typed, picked out.
20
+ function Marked({ text, query }: { text: string; query?: string }) {
21
+ const at = query ? text.toLowerCase().indexOf(query) : -1;
22
+ if (at < 0 || !query) return <>{text}</>;
23
+ return (
24
+ <>
25
+ {text.slice(0, at)}
26
+ <mark className="text-primary bg-transparent font-semibold">
27
+ {text.slice(at, at + query.length)}
28
+ </mark>
29
+ {text.slice(at + query.length)}
30
+ </>
31
+ );
13
32
  }
14
33
 
15
34
  // The menu above the composer: "/" commands and skills, "@" files and
@@ -22,6 +41,7 @@ export function ComposerMenu({
22
41
  onPick,
23
42
  onHover,
24
43
  refresh,
44
+ highlight,
25
45
  }: {
26
46
  label: string;
27
47
  empty: string;
@@ -31,6 +51,7 @@ export function ComposerMenu({
31
51
  onHover: (index: number) => void;
32
52
  // Reloads the list (a skill added since it was read).
33
53
  refresh?: { onClick: () => void; busy: boolean };
54
+ highlight?: string;
34
55
  }) {
35
56
  const listRef = useRef<HTMLDivElement>(null);
36
57
 
@@ -69,40 +90,59 @@ export function ComposerMenu({
69
90
  <p className="text-muted-foreground px-2.5 py-2 text-sm">{empty}</p>
70
91
  ) : (
71
92
  options.map((o, i) => (
72
- <button
73
- key={o.key}
74
- type="button"
75
- role="option"
76
- aria-selected={i === active}
77
- data-index={i}
78
- onMouseEnter={() => onHover(i)}
79
- // Keep focus in the textarea so the keyboard stays up on phones.
80
- onMouseDown={(e) => e.preventDefault()}
81
- onClick={() => onPick(i)}
82
- className={cn(
83
- "flex min-h-11 w-full flex-col items-start justify-center rounded-lg px-2.5 py-1.5 text-left md:min-h-9",
84
- i === active
85
- ? "bg-foreground/[0.07]"
86
- : "hover:bg-foreground/[0.04]"
93
+ <Fragment key={o.key}>
94
+ {o.group && o.group !== options[i - 1]?.group && (
95
+ <div className="text-muted-foreground px-2.5 pt-2 pb-0.5 text-[11px] tracking-wide uppercase">
96
+ {o.group}
97
+ </div>
87
98
  )}
88
- >
89
- <span className="flex w-full min-w-0 items-center gap-2">
90
- {o.icon}
91
- <span className="max-w-[75%] shrink-0 truncate font-mono text-sm">
92
- {o.label}
99
+ <button
100
+ type="button"
101
+ role="option"
102
+ aria-selected={i === active}
103
+ data-index={i}
104
+ onMouseEnter={() => onHover(i)}
105
+ // Keep focus in the textarea so the keyboard stays up on phones.
106
+ onMouseDown={(e) => e.preventDefault()}
107
+ onClick={() => onPick(i)}
108
+ className={cn(
109
+ "flex min-h-11 w-full flex-col items-start justify-center rounded-lg px-2.5 py-1.5 text-left md:min-h-9",
110
+ i === active
111
+ ? "bg-foreground/[0.07]"
112
+ : "hover:bg-foreground/[0.04]"
113
+ )}
114
+ >
115
+ <span className="flex w-full min-w-0 items-center gap-2">
116
+ {o.icon}
117
+ <span className="max-w-[65%] shrink-0 truncate font-mono text-sm">
118
+ {o.icon ? (
119
+ o.label
120
+ ) : (
121
+ <Marked text={o.label} query={highlight} />
122
+ )}
123
+ </span>
124
+ {o.hint && (
125
+ <span className="text-muted-foreground/70 min-w-0 truncate font-mono text-xs">
126
+ {o.hint}
127
+ </span>
128
+ )}
129
+ {o.badge && (
130
+ <span className="text-muted-foreground border-border ml-auto shrink-0 rounded-full border px-2 text-[10px]">
131
+ {o.badge}
132
+ </span>
133
+ )}
93
134
  </span>
94
- {o.hint && (
95
- <span className="text-muted-foreground/70 min-w-0 truncate font-mono text-xs">
96
- {o.hint}
135
+ {o.description && (
136
+ <span className="text-muted-foreground line-clamp-2 w-full text-xs">
137
+ {o.icon ? (
138
+ o.description
139
+ ) : (
140
+ <Marked text={o.description} query={highlight} />
141
+ )}
97
142
  </span>
98
143
  )}
99
- </span>
100
- {o.description && (
101
- <span className="text-muted-foreground w-full truncate text-xs">
102
- {o.description}
103
- </span>
104
- )}
105
- </button>
144
+ </button>
145
+ </Fragment>
106
146
  ))
107
147
  )}
108
148
  </div>
@@ -16,7 +16,14 @@ import {
16
16
  composeMessage,
17
17
  type TextAttachment,
18
18
  } from "@/lib/chat/paste";
19
- import { insertCommand, rankCommands, slashQuery } from "@/lib/chat/commands";
19
+ import {
20
+ groupCommands,
21
+ insertCommand,
22
+ leadingCommand,
23
+ slashQuery,
24
+ sourceLabel,
25
+ } from "@/lib/chat/commands";
26
+ import { loadRecent, saveRecent } from "@/lib/chat/recent-commands";
20
27
  import { mentionOption } from "@/lib/chat/mentions";
21
28
  import { cn } from "@/lib/utils";
22
29
  import { loadDraft, useSaveDraft } from "../useDraft";
@@ -184,25 +191,38 @@ export function ComposerBody({
184
191
  const slash = slashQuery(caret.before);
185
192
  const query = slash?.query ?? null;
186
193
  const mentions = useMentions(editor, caret, query === null, requestFiles);
187
- const matches = useMemo(() => {
194
+ // Only read when the menu is open, so rendering on the server is harmless.
195
+ const [recent, setRecent] = useState(loadRecent);
196
+ const groups = useMemo(() => {
188
197
  if (query === null) return [];
189
198
  const all = [
190
199
  ...(onSetModel ? [MODEL_COMMAND] : []),
191
200
  ...commands.filter((c) => c.name !== "model"),
192
201
  ];
193
- return rankCommands(query, all);
194
- }, [query, commands, onSetModel]);
202
+ return groupCommands(query, all, recent);
203
+ }, [query, commands, onSetModel, recent]);
204
+ const rows = useMemo(
205
+ () => groups.flatMap((g) => g.commands.map((command) => ({ g, command }))),
206
+ [groups]
207
+ );
208
+ const matches = useMemo(() => rows.map((r) => r.command), [rows]);
195
209
  // One menu at a time: "/" commands or "@" files at the caret.
196
210
  const menuKey =
197
211
  query !== null ? `/${caret.pos}:${caret.before}` : mentions.key;
198
212
  const menuOpen = menuKey !== null && dismissed !== menuKey;
199
213
  const options: MenuOption[] =
200
214
  query !== null
201
- ? matches.map((c, i) => ({
215
+ ? rows.map(({ g, command: c }, i) => ({
202
216
  key: `${c.name}-${i}`,
203
217
  label: `/${c.name}`,
204
218
  hint: c.argumentHint,
205
219
  description: c.description,
220
+ group: g.label || undefined,
221
+ // The heading already says it, except where a section mixes sources.
222
+ badge:
223
+ g.label === "Recent" || g.label === "Matched in description"
224
+ ? (sourceLabel(c) ?? undefined)
225
+ : undefined,
206
226
  }))
207
227
  : mentions.files.map((f) => ({
208
228
  key: `${f.dir ? "d" : "f"}:${f.path}`,
@@ -251,6 +271,9 @@ export function ComposerBody({
251
271
  !text.trim() && !images.length && !files.length && !attach.uploads.length;
252
272
  const submit = () => {
253
273
  if (empty || disabled || attach.uploading) return;
274
+ const used = leadingCommand(text)?.slice(1);
275
+ if (used && commands.some((c) => c.name === used))
276
+ setRecent(saveRecent(used));
254
277
  onSend(composeMessage(text, files), images, attached);
255
278
  setImages([]);
256
279
  setFiles([]);
@@ -308,6 +331,7 @@ export function ComposerBody({
308
331
  label={query !== null ? "Commands" : "Files"}
309
332
  empty={query !== null ? "No matching commands" : "No matching files"}
310
333
  options={options}
334
+ highlight={query ?? undefined}
311
335
  active={highlighted}
312
336
  onPick={pick}
313
337
  onHover={setActive}
@@ -1,4 +1,4 @@
1
- import type { ChatCommand } from "./events";
1
+ import type { ChatCommand, CommandSource } from "./events";
2
2
 
3
3
  // The /command being typed: a "/" at the start of a line or after whitespace,
4
4
  // then the partial name, right up to the caret. Not "a/b" or "http://x".
@@ -26,10 +26,15 @@ function score(q: string, c: ChatCommand): number {
26
26
  if (parts.some((p) => p.startsWith(q))) return 60;
27
27
  if (name.includes(q)) return 50;
28
28
  if (isSubsequence(q, name)) return 20;
29
- if (c.description.toLowerCase().includes(q)) return 10;
29
+ const desc = c.description.toLowerCase();
30
+ if (desc.split(/[^a-z0-9]+/).some((w) => w.startsWith(q))) return 12;
31
+ if (desc.includes(q)) return 10;
30
32
  return 0;
31
33
  }
32
34
 
35
+ // A match that only came from the description, not the name.
36
+ const DESCRIPTION_ONLY = 12;
37
+
33
38
  // Best matches first; ties keep the agent's own order.
34
39
  export function rankCommands(
35
40
  query: string,
@@ -68,3 +73,68 @@ export function withFresh(commands: ChatCommand[]): ChatCommand[] {
68
73
  ? commands
69
74
  : [FRESH_COMMAND, ...commands];
70
75
  }
76
+
77
+ export interface MenuGroup {
78
+ label: string;
79
+ commands: ChatCommand[];
80
+ }
81
+
82
+ const SOURCE_ORDER: CommandSource[] = [
83
+ "project",
84
+ "global",
85
+ "plugin",
86
+ "builtin",
87
+ ];
88
+
89
+ export const SOURCE_LABEL: Record<CommandSource, string> = {
90
+ project: "Project",
91
+ global: "Global",
92
+ plugin: "Plugin",
93
+ builtin: "Built-in",
94
+ };
95
+
96
+ // "Global" / "Project" / "slack": the small label on a row.
97
+ export function sourceLabel(c: ChatCommand): string | null {
98
+ if (!c.source) return null;
99
+ return c.source === "plugin" && c.plugin ? c.plugin : SOURCE_LABEL[c.source];
100
+ }
101
+
102
+ // The ranked commands as the menu shows them: what was used last, then one
103
+ // section per source, then what only matched by description. A command with
104
+ // no known source (an agent that doesn't report one) stays in one flat list.
105
+ export function groupCommands(
106
+ query: string,
107
+ commands: ChatCommand[],
108
+ recent: string[]
109
+ ): MenuGroup[] {
110
+ const ranked = rankCommands(query, commands);
111
+ if (!commands.some((c) => c.source)) {
112
+ return ranked.length ? [{ label: "", commands: ranked }] : [];
113
+ }
114
+ const groups: MenuGroup[] = [];
115
+ const add = (label: string, list: ChatCommand[]) => {
116
+ if (list.length) groups.push({ label, commands: list });
117
+ };
118
+ const rest = (c: ChatCommand) => !recent.includes(c.name);
119
+ const used = recent
120
+ .map((n) => ranked.find((c) => c.name === n))
121
+ .filter((c): c is ChatCommand => !!c);
122
+ add("Recent", used);
123
+ const byName = (a: ChatCommand, b: ChatCommand) =>
124
+ a.name.localeCompare(b.name);
125
+ const named = ranked.filter(
126
+ (c) => rest(c) && (!query || score(query, c) > DESCRIPTION_ONLY)
127
+ );
128
+ for (const src of SOURCE_ORDER) {
129
+ const list = named.filter((c) => (c.source ?? "builtin") === src);
130
+ // Alphabetical when nothing was typed; best match first otherwise.
131
+ add(SOURCE_LABEL[src], query ? list : [...list].sort(byName));
132
+ }
133
+ add(
134
+ "Matched in description",
135
+ ranked.filter(
136
+ (c) => rest(c) && query && score(query, c) <= DESCRIPTION_ONLY
137
+ )
138
+ );
139
+ return groups;
140
+ }
@@ -21,8 +21,14 @@ export interface ChatCommand {
21
21
  description: string;
22
22
  argumentHint?: string;
23
23
  builtin?: boolean;
24
+ // Where it comes from, for grouping in the "/" menu.
25
+ source?: CommandSource;
26
+ // The plugin that ships it, when source is "plugin".
27
+ plugin?: string;
24
28
  }
25
29
 
30
+ export type CommandSource = "project" | "global" | "plugin" | "builtin";
31
+
26
32
  export interface ChatModel {
27
33
  value: string;
28
34
  label: string;
@@ -0,0 +1,28 @@
1
+ // The commands and skills last used from the composer, newest first. Kept in
2
+ // the browser: a short list that only matters to this device.
3
+
4
+ const KEY = "agentos:recent-commands";
5
+ const MAX = 5;
6
+
7
+ export function pushRecent(list: string[], name: string): string[] {
8
+ return [name, ...list.filter((n) => n !== name)].slice(0, MAX);
9
+ }
10
+
11
+ export function loadRecent(): string[] {
12
+ try {
13
+ const v: unknown = JSON.parse(localStorage.getItem(KEY) ?? "[]");
14
+ return Array.isArray(v)
15
+ ? v.filter((n): n is string => typeof n === "string").slice(0, MAX)
16
+ : [];
17
+ } catch {
18
+ return [];
19
+ }
20
+ }
21
+
22
+ export function saveRecent(name: string): string[] {
23
+ const next = pushRecent(loadRecent(), name);
24
+ try {
25
+ localStorage.setItem(KEY, JSON.stringify(next));
26
+ } catch {}
27
+ return next;
28
+ }
@@ -16,6 +16,7 @@ import {
16
16
  type ChatCommand,
17
17
  type ChatServerMessage,
18
18
  } from "./events";
19
+ import { tagSources } from "./skill-source";
19
20
  import {
20
21
  createSkillWatcher,
21
22
  skillFolders,
@@ -200,6 +201,7 @@ async function loadCapabilities(
200
201
  if (w.loads.get(key)?.gen !== gen) return;
201
202
  registry.caps.set(key, {
202
203
  ...found,
204
+ commands: tagSources(found.commands, cwdOf(session)),
203
205
  terminalOnly: registry.caps.get(key)?.terminalOnly ?? new Set(),
204
206
  at: Date.now(),
205
207
  });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Tells the "/" menu where each command or skill comes from: the project's
3
+ * own folders, the user's, a plugin (its name carries the plugin as a
4
+ * "plugin:" prefix), or the agent itself.
5
+ */
6
+
7
+ import fs from "fs";
8
+ import os from "os";
9
+ import path from "path";
10
+ import { SKILL_DIRS } from "../agents/skill-dirs";
11
+ import type { ChatCommand } from "./events";
12
+
13
+ const claudeDir = () =>
14
+ process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
15
+
16
+ function entries(dir: string): fs.Dirent[] {
17
+ try {
18
+ return fs.readdirSync(dir, { withFileTypes: true });
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ const isDir = (e: fs.Dirent, dir: string) =>
25
+ e.isDirectory() ||
26
+ (e.isSymbolicLink() && fs.statSync(path.join(dir, e.name)).isDirectory());
27
+
28
+ // The name in a SKILL.md's frontmatter, when it differs from the folder.
29
+ function declaredName(file: string): string | null {
30
+ try {
31
+ const head = fs.readFileSync(file, "utf8").slice(0, 1024);
32
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(head)?.[1] ?? "";
33
+ return /^name:\s*["']?([^\s"']+)/m.exec(fm)?.[1] ?? null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ // Names a folder of skills and commands offers: skill folders, and command
40
+ // files (nested ones as "dir:name").
41
+ export function namesIn(root: string): Set<string> {
42
+ const names = new Set<string>();
43
+ const skills = path.join(root, "skills");
44
+ for (const e of entries(skills)) {
45
+ try {
46
+ if (!isDir(e, skills)) continue;
47
+ } catch {
48
+ continue;
49
+ }
50
+ names.add(e.name);
51
+ const declared = declaredName(path.join(skills, e.name, "SKILL.md"));
52
+ if (declared) names.add(declared);
53
+ }
54
+ const commands = path.join(root, "commands");
55
+ const walk = (dir: string, prefix: string) => {
56
+ for (const e of entries(dir)) {
57
+ if (e.isDirectory()) walk(path.join(dir, e.name), `${prefix}${e.name}:`);
58
+ else if (e.name.endsWith(".md"))
59
+ names.add(`${prefix}${e.name.slice(0, -3)}`);
60
+ }
61
+ };
62
+ walk(commands, "");
63
+ return names;
64
+ }
65
+
66
+ function projectNames(cwd: string): Set<string> {
67
+ const names = new Set<string>();
68
+ const home = os.homedir();
69
+ // The folder and its parents, up to home: a repo's skills reach its subfolders.
70
+ for (let dir = cwd; dir !== home && dir !== path.dirname(dir);) {
71
+ for (const rel of SKILL_DIRS) {
72
+ // SKILL_DIRS are ".claude/skills" style; namesIn wants the parent.
73
+ for (const n of namesIn(path.join(dir, path.dirname(rel)))) names.add(n);
74
+ }
75
+ dir = path.dirname(dir);
76
+ }
77
+ return names;
78
+ }
79
+
80
+ export function tagSources(
81
+ commands: ChatCommand[],
82
+ cwd: string
83
+ ): ChatCommand[] {
84
+ const project = projectNames(cwd);
85
+ const user = namesIn(claudeDir());
86
+ return commands.map((c) => {
87
+ if (project.has(c.name)) return { ...c, source: "project" };
88
+ if (user.has(c.name)) return { ...c, source: "global" };
89
+ if (c.builtin) return { ...c, source: "builtin" };
90
+ const colon = c.name.indexOf(":");
91
+ if (colon > 0)
92
+ return { ...c, source: "plugin", plugin: c.name.slice(0, colon) };
93
+ return { ...c, source: "builtin" };
94
+ });
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.756",
3
+ "version": "0.3.758",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"