@nanhara/hara 0.109.3 → 0.109.4

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.109.4 — a dropped file path is read, not "Unknown command"
9
+
10
+ - **Dragging/pasting a file into the prompt no longer errors.** A dropped file pastes as an absolute path
11
+ (`/Users/…/spec.md`, often with trailing text or `[Image #N]` tokens). Because it starts with `/`, hara
12
+ treated it as a slash command and replied `Unknown command /Users/…`. Now the command parser only fires
13
+ when the first token has **no embedded slash** (real commands — `/help`, `/design` — never contain one),
14
+ so a path falls through to the normal turn. And when a message *begins* with an existing absolute path,
15
+ that path is rewritten to an `@`-mention so its content is **read into the turn** — i.e. "interpret this
16
+ file" just works. Fixed in both the TUI and the readline REPL.
17
+
8
18
  ## 0.109.3 — an empty model response no longer looks like a hang
9
19
 
10
20
  - **A turn that comes back with no text and no tool calls is no longer silently dropped.** The agent
@@ -8,6 +8,23 @@ import { mediaTypeFor } from "../images.js";
8
8
  const MAX_FILE = 50_000;
9
9
  // @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
10
10
  const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
11
+ /** A submitted line is a slash COMMAND only if it starts with '/' AND its first whitespace-delimited
12
+ * token has no *embedded* '/'. A dropped/pasted absolute file path (`/Users/…/doc.md`, possibly followed
13
+ * by text or `[Image #N]` tokens) has an embedded slash → it is NOT a command. Without this, dragging a
14
+ * file into the prompt errored with "Unknown command /Users/…". Exported pure for tests. */
15
+ export function isSlashCommand(line) {
16
+ return line.startsWith("/") && !line.slice(1).split(/\s+/)[0].includes("/");
17
+ }
18
+ /** If a message BEGINS with an absolute file path (a dragged/pasted file), rewrite that leading path into
19
+ * an `@`-mention so expandMentions inlines the file's content into the turn — the "read/interpret this
20
+ * file" the user meant. Space-free leading paths only (a mention can't contain spaces); a non-existent or
21
+ * spaced path is left untouched. `exists` is injected so this stays pure/testable. */
22
+ export function inlineLeadingPath(line, exists) {
23
+ if (!line.startsWith("/"))
24
+ return line;
25
+ const first = line.split(/\s+/)[0];
26
+ return exists(first) ? "@" + line : line;
27
+ }
11
28
  /** Expand `@path` references **in place** — the file/dir content lands where it's referenced, not
12
29
  * dumped at the bottom (so "compare @a.ts with @b.ts" reads in context). A repeated mention keeps
13
30
  * the bare `@path` the second time (no double-inlining), and a non-readable ref is left untouched. */
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ import { loadAgentsMd, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./conte
47
47
  import { getEmbedder } from "./search/embed.js";
48
48
  import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
49
49
  import { searchHybrid } from "./search/hybrid.js";
50
- import { expandMentions, fileCandidates } from "./context/mentions.js";
50
+ import { expandMentions, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
51
51
  import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
52
52
  import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
53
53
  import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
@@ -2814,7 +2814,9 @@ program.action(async (opts) => {
2814
2814
  onClipboardImage: readClipboardImage,
2815
2815
  vim: cfg.vimMode,
2816
2816
  onSubmit: async (line, h, images) => {
2817
- if (line.startsWith("/")) {
2817
+ // A dropped/pasted file path (`/Users/…/doc.md`, maybe trailing text/images) starts with '/' but
2818
+ // is NOT a command — treat it as a file to read, not "Unknown command" (see isSlashCommand).
2819
+ if (isSlashCommand(line)) {
2818
2820
  const [nm, ...rest] = line.slice(1).split(/\s+/);
2819
2821
  const arg = rest.join(" ").trim();
2820
2822
  if (nm === "exit" || nm === "quit") {
@@ -3074,6 +3076,9 @@ program.action(async (opts) => {
3074
3076
  const near = nearest(nm, [...byName.keys()]);
3075
3077
  return void h.sink.notice(`Unknown command /${nm}.${near.length ? " Did you mean " + near.map((n) => "/" + n).join(", ") + "?" : ""}`);
3076
3078
  }
3079
+ // A message that begins with an absolute file path (the case skipped above) → inline it as an
3080
+ // @-mention so its content is read into the turn (drag-a-file-in → interpret it).
3081
+ line = inlineLeadingPath(line, existsSync);
3077
3082
  const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
3078
3083
  const appr = h.approval;
3079
3084
  // Type-ahead steering: fold messages typed mid-turn into the next model call (codex-style) so a
@@ -3237,7 +3242,8 @@ program.action(async (opts) => {
3237
3242
  bar.renderBottom(); // bottom border + modes/usage
3238
3243
  if (!line)
3239
3244
  continue;
3240
- if (line.startsWith("/")) {
3245
+ // A dropped/pasted file path starts with '/' but isn't a command — read it, don't error (see TUI path).
3246
+ if (isSlashCommand(line)) {
3241
3247
  const [name, ...rest] = line.slice(1).split(/\s+/);
3242
3248
  const cmd = byName.get(name);
3243
3249
  if (!cmd) {
@@ -3275,6 +3281,7 @@ program.action(async (opts) => {
3275
3281
  break;
3276
3282
  continue;
3277
3283
  }
3284
+ line = inlineLeadingPath(line, existsSync); // leading dropped file path → @-mention so it's read in
3278
3285
  const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd);
3279
3286
  recalledContext = "";
3280
3287
  history.push({ role: "user", content: userContent });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.109.3",
3
+ "version": "0.109.4",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"