Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
- The command palette opens on an empty search every time. It no longer shows the previous
search's results for a moment, during which a quick Tab could highlight a different row than the
one it focused. ([#631](https://github.com/devswha/herdr-web-ui/pull/631))
- A chat message that ends with an attached file's `@` mention is sent again. Pasted last, the
mention left the agent's file suggestions open, and the Enter took a suggestion instead of
sending, so the message stayed in the terminal's input box.
([#635](https://github.com/devswha/herdr-web-ui/pull/635))

## [0.4.2] - 2026-10-09

Expand Down
19 changes: 16 additions & 3 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,19 @@ export function createServer(
* the bytes, so the pane sees the whole gap. So does a Codex "blocked" only by questions
* waiting collapsed in its queue: its main prompt still takes the message.
*/
/**
* A message whose last word is an `@file` mention leaves the agent's file suggestions open over
* it, and the Enter after the paste takes the suggestion instead of sending: the message stays in
* the input box (Claude Code 2.1.295, #404). A space after the mention closes them first; a
* payload already in bracketed-paste markers gets it inside them.
*/
const closeMention = (text: string): string => {
const open = text.startsWith("\u001b[200~") ? "\u001b[200~" : "";
const close = open && text.endsWith("\u001b[201~") ? "\u001b[201~" : "";
const body = text.slice(open.length, text.length - close.length);
return /(?:^|\s)@\S+$/.test(body) ? `${open}${body} ${close}` : text;
};

async function submitText(paneId: string, text: string, payload: string, arrivedAt: number, fromTerminal = false, authorize: () => void = () => {}): Promise<void> {
const inTime = (): void => {
authorize();
Expand All @@ -457,7 +470,7 @@ export function createServer(
// the terminal's input line stands in for the keyboard: it types what the user wrote, an
// answer into an open menu included, where agent.prompt would refuse
if (!fromTerminal) try {
await agentPrompt(paneId, text);
await agentPrompt(paneId, closeMention(text));
Comment thread
devswha marked this conversation as resolved.
Resolved
noteSubmitted(paneId, text);
return;
} catch (error) {
Expand All @@ -469,7 +482,7 @@ export function createServer(
// a mirrored pane's browser never learned the program's paste mode, so `payload` came as bare
// lines: several of them are shaped here as the same block typed into the mirror is. herdr is
// asked only for such a block, so a one-line message never waits on it.
const shaped = await mirrorInput(payload, async () => await terminalAttach() ? null : (await paneContext(paneId)).agent);
const shaped = await mirrorInput(closeMention(payload), async () => await terminalAttach() ? null : (await paneContext(paneId)).agent);
inTime();
await paneSendText(paneId, shaped);
await Bun.sleep(options.submitDelayMs ?? SUBMIT_DELAY_MS);
Expand Down Expand Up @@ -611,7 +624,7 @@ export function createServer(
// Pending input is a guarded literal paste + Enter, never Codex's native Tab queue.
// Keep the final key here so a disconnect, pane switch or new menu can cancel it.
wrote = true;
await paneSendText(paneId, `\u001b[200~${text}\u001b[201~`);
await paneSendText(paneId, `\u001b[200~${closeMention(text)}\u001b[201~`);
await Bun.sleep(options.submitDelayMs ?? SUBMIT_DELAY_MS);
const beforeEnter = await pendingContext(owner, paneId, lease, identity, true);
if (automatic && beforeEnter.working) throw new HerdrError("pending_wait", "The agent started another turn before this queued message could be committed");
Expand Down
7 changes: 7 additions & 0 deletions server/pending-input.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ describe("connection-owned pending input", () => {
expect(f.bytes()).toBe(`${paste(first.text)}\r${paste("second")}\r`);
}, 30_000);

it("closes a trailing @file mention with a space before its Enter", async () => {
const f = await setup("mention");
const message = await f.queue(1, "look at @/tmp/shot.png");
await f.state("idle"); await f.removed(message.id); await f.waitBytes("\r");
expect(f.bytes()).toBe(`${paste("look at @/tmp/shot.png ")}\r`);
}, 30_000);

it("promotes a pending message only once and does not send it again after completion", async () => {
const f = await setup("promote"); const message = await f.queue(1, "steer once");
f.socket.send({ type: "pending-action", id: 10, pane_id: f.pane, pending_id: message.id, action: "steer" });
Expand Down
28 changes: 28 additions & 0 deletions server/submit.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,20 @@ describe("WebSocket submit", () => {
}
}, 30_000);

it("closes a trailing @file mention inside the paste markers of a payload typed without agent.prompt", async () => {
const socket = await Socket.connect();
try {
const from = chunks(shell).length;
socket.send({ type: "submit", id: 21, pane_id: shell.pane, text: "look at @/tmp/shot.png", payload: paste("look at @/tmp/shot.png") });
expect(await socket.result(21)).toMatchObject({ ok: true });
const read = await received(shell, from, 1);
const enter = read.findIndex((chunk) => chunk.data.includes("\r"));
expect(read.slice(0, enter).map((chunk) => chunk.data).join("")).toBe(paste("look at @/tmp/shot.png "));
} finally {
socket.close();
}
}, 30_000);

it("hands an agent the message through herdr's agent.prompt: the paste, then Enter apart", async () => {
const socket = await Socket.connect();
try {
Expand All @@ -186,6 +200,20 @@ describe("WebSocket submit", () => {
}
}, 30_000);

it("closes a trailing @file mention with a space, so the Enter sends rather than takes a file suggestion", async () => {
const socket = await Socket.connect();
try {
const from = chunks(agent).length;
socket.send({ type: "submit", id: 20, pane_id: agent.pane, text: "what is in @/tmp/shot.png", payload: "unused" });
expect(await socket.result(20)).toMatchObject({ ok: true });
const read = await received(agent, from, 1);
const enter = read.findIndex((chunk) => chunk.data.includes("\r"));
expect(read.slice(0, enter).map((chunk) => chunk.data).join("")).toBe(paste("what is in @/tmp/shot.png "));
} finally {
socket.close();
}
}, 30_000);

it("refuses a message while the agent waits for an answer, typing nothing", async () => {
const socket = await Socket.connect();
await herdrRpc("pane.report_agent", { pane_id: agent.pane, source: "manual", agent: "claude", state: "blocked" });
Expand Down
Loading