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
6 changes: 6 additions & 0 deletions changelog.d/hermes-chat-draft.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Fixed
- A chat message to Hermes no longer joins text already typed into Hermes' input in the terminal,
where the paste and its Enter sent both as one message. As with Claude Code, a send or a held
message's delivery is refused while that input holds anything but Hermes' own hint, and the chat
says so ([#1149](https://github.com/devswha/herdr-web-ui/pull/1149),
[#859](https://github.com/devswha/herdr-web-ui/pull/859) by @RikiSanjayaa).
31 changes: 28 additions & 3 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ import {
worktreeRemove,
} from "./herdr/client.ts";
import { type AlertTiming, createPushService, defaultStateDir, handlePushRequest } from "./push.ts";
import { CLAUDE_CLEAR_INPUT_KEYS, claudeClearableDraft, claudeHeldIsGrey, claudeInputBoxContent, claudeInputDraft, shownInputBox, claudeInputEmpty, viewportShowsLive, codexQuestionsCollapsed, handlePromptRequest, isClaudeHeld, heldCandidate, modelListWaits, noteSubmitted, parseInteractivePrompt, promptWaitEnded, claudeRestoredDraft, lastChatSubmitted } from "./prompt.ts";
import { CLAUDE_CLEAR_INPUT_KEYS, claudeClearableDraft, claudeHeldIsGrey, claudeInputBoxContent, claudeInputDraft, hermesInputDraft, shownInputBox, claudeInputEmpty, viewportShowsLive, codexQuestionsCollapsed, handlePromptRequest, isClaudeHeld, heldCandidate, modelListWaits, noteSubmitted, parseInteractivePrompt, promptWaitEnded, claudeRestoredDraft, lastChatSubmitted } from "./prompt.ts";
import { secretPrompt, validSecret } from "../shared/secret-prompt.ts";
import { PasteImageError, readImageBody, savePaneImage } from "./paste.ts";
import { PtySession } from "./pty/session.ts";
Expand Down Expand Up @@ -201,6 +201,7 @@ const MAX_WAITING_KEYS = 256;
*/
export const SUBMIT_DEADLINE_MS = 45_000;
const CLAUDE_INPUT_DRAFT_MESSAGE = "Claude Code's input box is not empty (a draft, bash mode, or a box that could not be read); send or clear it in the terminal, then send this message";
const HERMES_INPUT_DRAFT_MESSAGE = "Hermes' input in the terminal is not empty (a draft, or an input that could not be read); send or clear it there, then send this message";
/** How long a cleared Claude input box may take to read empty before the send is refused after all. */
const DRAFT_CLEAR_WAIT_MS = 1_500;
const DRAFT_CLEAR_POLL_MS = 50;
Expand All @@ -212,8 +213,8 @@ const DRAFT_CLEAR_POLL_MS = 50;
*/
class InputDraftError extends HerdrError {
readonly draft: string | null;
constructor(draft: string | null, readonly identity?: PendingIdentity, readonly box: string | null = null) {
super("input_draft", CLAUDE_INPUT_DRAFT_MESSAGE);
constructor(draft: string | null, readonly identity?: PendingIdentity, readonly box: string | null = null, message = CLAUDE_INPUT_DRAFT_MESSAGE) {
super("input_draft", message);
this.draft = draft;
}
}
Expand Down Expand Up @@ -746,6 +747,11 @@ export function createServer(
}
if (clearDraft !== undefined && !claudeInputEmpty(...reads)) throw new InputDraftError(null);
}
// a clear request or send anyway reached a Hermes pane only to be refused above: both are Claude's
if ((pane?.agent ?? pane?.agent_session?.agent) === "hermes") {
await freeHermesInput(paneId, identity ?? undefined);
inTime();
}
try {
await agentPrompt(paneId, closeMention(text));
noteSubmitted(paneId, text);
Expand Down Expand Up @@ -893,6 +899,24 @@ export function createServer(
* they show it. Read last, the live screen holds anything typed meanwhile. An empty box (or none)
* needs no colors.
*/
/**
* Hermes' input, refused with input_draft unless hermesInputDraft knows it empty. As claudeBoxReads,
* the viewport's colors are read only for an input row with text, and count only once
* viewportShowsLive verifies them; the live read comes last, so text typed meanwhile is in it.
* Hermes offers nothing to clear from here: the user sends or clears it in the terminal.
*/
async function freeHermesInput(paneId: string, identity?: PendingIdentity): Promise<void> {
const before = (await paneRead({ paneId, source: "detection", format: "text" })).text;
if (!hermesInputDraft(before, null)) return;
const scrollBefore = await paneScrollInfo(paneId);
const colors = (await paneRead({ paneId, source: "visible", format: "ansi" })).text;
const scrollAfter = await paneScrollInfo(paneId);
const live = (await paneRead({ paneId, source: "detection", format: "text" })).text;
if (hermesInputDraft(live, viewportShowsLive(scrollBefore, scrollAfter, colors, before, live) ? colors : null)) {
throw new InputDraftError(null, identity, null, HERMES_INPUT_DRAFT_MESSAGE);
}
}

async function claudeBoxReads(paneId: string): Promise<[string, string | null]> {
const before = (await paneRead({ paneId, source: "detection", format: "text" })).text;
if (!claudeInputDraft(before, null)) return [before, null];
Expand Down Expand Up @@ -950,6 +974,7 @@ export function createServer(
// The box is the live screen's; only the viewport read tells Claude's grey text from a draft. The live
// read comes after the colors, so text typed between the two is in the box and holds the message.
if (context.identity.agent === "claude") await freeClaudeBox(paneId, context.working, () => authorizePending(owner, paneId, lease), arrivedAt + (options.submitDeadlineMs ?? SUBMIT_DEADLINE_MS));
else if (context.identity.agent === "hermes") await freeHermesInput(paneId, context.identity);
if (Date.now() - arrivedAt > (options.submitDeadlineMs ?? SUBMIT_DEADLINE_MS)) throw new HerdrError("submit_timeout", "The pending message waited too long; nothing was typed");
authorizePending(owner, paneId, lease);
// asked again right before herdr is written to: its connect is awaited (#666)
Expand Down
43 changes: 42 additions & 1 deletion server/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { InteractivePrompt } from "../shared/protocol.ts";

import { answerKeys, claudeClearableDraft, claudeInputBoxText, claudeInputDraft, claudeInputEmpty, viewportShowsLive, removedInvisible, noteSubmitted, codexQuestionsCollapsed, codexQueuedPrompt, handlePromptRequest, modelListWaits, openOmoAsks, parseClaudeSuggestion, parseFallbackPrompt, parseInteractivePrompt, pendingOmoAsk, promptWaitEnded, claudeRestoredDraft, lastChatSubmitted } from "./prompt.ts";
import { answerKeys, hermesInputDraft, claudeClearableDraft, claudeInputBoxText, claudeInputDraft, claudeInputEmpty, viewportShowsLive, removedInvisible, noteSubmitted, codexQuestionsCollapsed, codexQueuedPrompt, handlePromptRequest, modelListWaits, openOmoAsks, parseClaudeSuggestion, parseFallbackPrompt, parseInteractivePrompt, pendingOmoAsk, promptWaitEnded, claudeRestoredDraft, lastChatSubmitted } from "./prompt.ts";

const labels = (prompt: InteractivePrompt | null) => prompt?.options.map((option) => option.label);
// Real omo-ai 5.1.30 pane.read captures at 120 and 45 columns, with the recorded open calls.
Expand Down Expand Up @@ -4995,3 +4995,44 @@ describe("Claude Code's model list under a hint wrapped anywhere", () => {
expect(modelListWaits("claude", `⏺ Enter to set as default · s to use this session only · Esc to cancel\n`)).toBe(false);
});
});

describe("hermesInputDraft", () => {
// input rows of real Hermes 0.21.5 screens (herdr pane read --source visible --format ansi)
const CLASSIC_HINT = "\u001b[0m\u001b[38;5;230m❯ \u001b[0m\u001b[3m\u001b[38;5;136mType / to browse commands, or Ctrl+P for the palette\u001b[0m";
const CLASSIC_TYPED = "\u001b[0m\u001b[38;5;230m❯ \u001b[0mReply with only the word";
const TUI_HINT = " \u001b[0m\u001b[1m\u001b[38;2;255;248;220m❯ \u001b[0m\u001b[38;2;255;255;255m\u001b[48;2;184;134;11mT\u001b[0m\u001b[38;2;184;134;11mry \"how does the config loader work?\"\u001b[0m";
const TUI_TYPED = " \u001b[0m\u001b[1m\u001b[38;2;255;248;220m❯ \u001b[0m\u001b[38;2;255;248;220mReply with only the word\u001b[0m ";
const screen = (input: string) => `● ❯ an earlier message, echoed\n────────\n${input}\n────────`;
const plain = (ansi: string) => ansi.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "");
const draft = (input: string, colors: string | null = screen(input)) => hermesInputDraft(plain(screen(input)), colors);

test("knows text typed into the classic CLI or the TUI for a draft", () => {
expect(draft(CLASSIC_TYPED)).toBe(true);
expect(draft(TUI_TYPED)).toBe(true);
// a `❯` the user typed is text, not the prompt
expect(draft("\u001b[0m\u001b[38;5;230m❯ \u001b[0mreview ❯ ")).toBe(true);
});

test("takes neither interface's own hint for a draft: the classic italic placeholder, the TUI's cursor chip", () => {
expect(draft(CLASSIC_HINT)).toBe(false);
expect(draft(TUI_HINT)).toBe(false);
});

test("reads an input with nothing after the prompt as empty, without colors", () => {
expect(draft(" \u001b[1m❯ \u001b[0m", null)).toBe(false);
});

test("knows a draft that holds a `❯` without colors too, and reads an empty prompt row with colors as empty", () => {
expect(draft("\u001b[0m\u001b[38;5;230m❯ \u001b[0mreview ❯ ", null)).toBe(true);
expect(draft("\u001b[0m\u001b[38;5;230m❯ \u001b[0m")).toBe(false);
});

test("counts any text as a draft without colors verified to show that screen", () => {
expect(draft(CLASSIC_HINT, null)).toBe(true);
expect(draft(TUI_HINT, screen(CLASSIC_TYPED))).toBe(true);
});

test("does not know a screen without an input row to be empty", () => {
expect(hermesInputDraft("Approve this command?\n [1] Approve once\n", null)).toBe(true);
});
});
67 changes: 67 additions & 0 deletions server/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2928,6 +2928,73 @@ function colorless(ansi: string): boolean {
return true;
}

/** A screen row without its escapes, OSC sequences (titles, links) included. */
const plainRow = (row: string): string => row.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "").replace(ANSI_RE, "");

/** The screen's bottom-most row with a `❯`: Hermes' input, below every message it echoes. */
function hermesInputRow(screen: string): string | null {
const rows = screen.split(/\r?\n/);
for (let index = rows.length - 1; index >= 0; index--) if (plainRow(rows[index]!).includes("❯")) return rows[index]!;
return null;
}

/**
* Whether Hermes' input is not known to be empty, so that a paste would join what is there and the
* Enter send both as one message (measured on Hermes 0.21.5, classic CLI and TUI). The input is the
* bottom-most `❯` row, empty when nothing follows the `❯`. Hermes' own hint there is not a draft:
* the classic CLI draws its placeholder italic (its skin's `placeholder: "{dim} italic"`), and the TUI
* starts its hint with a synthetic cursor, a cell with a background color (ui-tui textInput.tsx
* `hintCursorCell`); typed text has neither. `live` is the detection read; `colors` the viewport's
* ANSI read, passed only once it is verified to show that screen (viewportShowsLive), else null, and
* then any text after the `❯` counts as a draft. A screen without a `❯` row is not known to be empty.
*/
export function hermesInputDraft(live: string, colors: string | null): boolean {
const row = hermesInputRow(live);
if (row === null) return true;
const plain = plainRow(row);
// the row's first `❯` is the prompt: one after it is typed text
if (plain.slice(plain.indexOf("❯") + 1).replace(/\u00a0/g, " ").trim() === "") return false;
const shown = colors === null ? null : hermesInputRow(colors);
if (shown === null || plainRow(shown).trimEnd() !== plain.trimEnd()) return true;
const after = plainRow(shown).indexOf("❯");
let column = 0;
let italic = false;
let background = false;
// the first character after the `❯` decides: a TUI hint's cursor chip, or typed (not italic) text
let decided: boolean | null = null;
const visit = (text: string): void => {
for (const character of text) {
const at = column;
column += character.length;
if (decided !== null) return;
if (at <= after || character.trim() === "" || character === "\u00a0") continue;
if (background) { decided = false; return; }
if (!italic) { decided = true; return; }
}
};
const escape = /\u001b(?:\[([0-?]*)[ -/]*([@-~])|\][^\u0007\u001b]*(?:\u0007|\u001b\\)|[@-Z\\-_])/g;
let offset = 0;
for (const match of shown.matchAll(escape)) {
visit(shown.slice(offset, match.index));
offset = match.index! + match[0].length;
if (match[2] !== "m") continue;
const codes = (match[1] || "0").split(";").map((code) => Number(code || 0));
for (let index = 0; index < codes.length; index++) {
const code = codes[index]!;
if (code === 38 || code === 58) index += codes[index + 1] === 5 ? 2 : codes[index + 1] === 2 ? 4 : 0;
else if (code === 48) { background = true; index += codes[index + 1] === 5 ? 2 : codes[index + 1] === 2 ? 4 : 0; }
else if (code === 0) { italic = false; background = false; }
else if (code === 3) italic = true;
else if (code === 23) italic = false;
else if (code === 49) background = false;
else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) background = true;
}
}
visit(shown.slice(offset));
// every character italic: the classic CLI's placeholder
return decided ?? false;
}

/**
* Whether Claude Code's input box is not known to be empty, so that a paste would land in what is
* there and the Enter after it send both, or run it as a command: typed text, bash mode (`!`), a
Expand Down
55 changes: 55 additions & 0 deletions server/submit.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ let agent: Recorder;
let codex: Recorder;
let codexApproval: Recorder;
let claudeQueue: Recorder;
let hermes: Recorder;

const chunks = (recorder: Recorder): Chunk[] =>
readFileSync(recorder.log, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line) as Chunk);
Expand Down Expand Up @@ -157,6 +158,8 @@ beforeAll(async () => {
chmodSync(join(root, "claude"), 0o755);
copyFileSync(process.execPath, join(root, "codex"));
chmodSync(join(root, "codex"), 0o755);
copyFileSync(process.execPath, join(root, "hermes"));
chmodSync(join(root, "hermes"), 0o755);
shell = await recorder("shell", process.execPath);
agent = await recorder("agent", join(root, "claude"));
await herdrRpc("pane.report_agent", { pane_id: agent.pane, source: "manual", agent: "claude", state: "idle" });
Expand All @@ -170,6 +173,8 @@ beforeAll(async () => {
// the same text in a pane whose agent is not Codex
claudeQueue = await recorder("claude-queue", join(root, "claude"), `${queue}› Ask Codex to do anything\n`);
await herdrRpc("pane.report_agent", { pane_id: claudeQueue.pane, source: "manual", agent: "claude", state: "blocked" });
hermes = await recorder("hermes", join(root, "hermes"));
await herdrRpc("pane.report_agent", { pane_id: hermes.pane, source: "manual", agent: "hermes", state: "idle" });
}, 30_000);

afterAll(async () => {
Expand Down Expand Up @@ -525,6 +530,56 @@ describe("WebSocket submit", () => {
}
}, 30_000);

// Hermes 0.21.5's input rows (herdr pane read --source visible --format ansi): typed text, and the classic CLI's italic hint
const hermesScreen = (input: string) => `● ❯ an earlier message\r\n────────\r\n${input}\r\n────────`;
const HERMES_TYPED = "\u001b[0m\u001b[38;5;230m❯ \u001b[0mReply with only the word";
const HERMES_HINT = "\u001b[0m\u001b[38;5;230m❯ \u001b[0m\u001b[3m\u001b[38;5;136mType / to browse commands, or Ctrl+P for the palette\u001b[0m";

it("refuses an immediate Hermes chat send over text typed into its input, typing nothing", async () => {
const socket = await Socket.connect();
const screen = mockClaudeInput(hermes.pane, hermesScreen(HERMES_TYPED));
const prompt = spyOn(herdr, "agentPrompt").mockResolvedValue(undefined);
const sendText = spyOn(herdr, "paneSendText").mockResolvedValue(undefined);
const sendKeys = spyOn(herdr, "paneSendKeys").mockResolvedValue(undefined);
try {
socket.send({ type: "submit", id: 31, pane_id: hermes.pane, text: "PINEAPPLE", payload: "PINEAPPLE" });
const refused = await socket.result(31);
expect(refused).toMatchObject({ ok: false, code: "input_draft", message: "Hermes' input in the terminal is not empty (a draft, or an input that could not be read); send or clear it there, then send this message" });
// nothing to clear or send anyway: those are Claude's
expect(refused.draft).toBeUndefined();
expect(refused.box).toBeUndefined();
expect(prompt).not.toHaveBeenCalled();
expect(sendText).not.toHaveBeenCalled();
expect(sendKeys).not.toHaveBeenCalled();
} finally {
sendKeys.mockRestore();
sendText.mockRestore();
prompt.mockRestore();
screen.scroll.mockRestore();
screen.read.mockRestore();
socket.close();
}
}, 30_000);

it("sends an immediate Hermes chat message when its input shows only Hermes' own hint", async () => {
const socket = await Socket.connect();
const screen = mockClaudeInput(hermes.pane, hermesScreen(HERMES_HINT));
const prompt = spyOn(herdr, "agentPrompt").mockResolvedValue(undefined);
const sendText = spyOn(herdr, "paneSendText").mockResolvedValue(undefined);
try {
socket.send({ type: "submit", id: 32, pane_id: hermes.pane, text: "PINEAPPLE", payload: "PINEAPPLE" });
expect(await socket.result(32)).toMatchObject({ ok: true, pane_id: hermes.pane });
expect(prompt).toHaveBeenCalledWith(hermes.pane, "PINEAPPLE");
expect(sendText).not.toHaveBeenCalled();
} finally {
sendText.mockRestore();
prompt.mockRestore();
screen.scroll.mockRestore();
screen.read.mockRestore();
socket.close();
}
}, 30_000);

it("shows the box of a refusal that names no draft, then sends anyway once asked, through agent.prompt", async () => {
const socket = await Socket.connect();
const screen = mockClaudeInput(agent.pane, claudeInputScreen("! ls"));
Expand Down
7 changes: 4 additions & 3 deletions src/components/PaneTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2280,8 +2280,8 @@ export function PaneTerminal({

const sendComposerText = useCallback((text: string, delivery: "queue" | "immediate" = "immediate"): false | Promise<true | string> => {
const result = submitComposerMessage(text, delivery);
return result === null ? false : result.then((answer) => answer.ok ? true : submitNote(answer.code, answer.message));
}, [submitComposerMessage]);
return result === null ? false : result.then((answer) => answer.ok ? true : submitNote(answer.code, answer.message, agent));
}, [submitComposerMessage, agent]);

// the terminal's input line: the text typed like the keyboard would, into an agent's open
// menu too, then Enter after the server's gap; several lines go as one paste
Expand Down Expand Up @@ -2510,7 +2510,7 @@ export function PaneTerminal({
// Send anyway answered a box seen at once: an agent that started working since would queue it unchecked
if (sendAnyway && delivery !== "immediate") return t("Not sent: the agent started working since. Check the terminal, then send again.");
const result = submitComposerMessage(text, delivery, delivery === "immediate" ? clearDraft : undefined, sendAnyway);
return result === null ? false : result.then((answer) => answer.ok ? true : submitRefusal(answer.code, answer.message, answer.draft, answer.box));
return result === null ? false : result.then((answer) => answer.ok ? true : submitRefusal(answer.code, answer.message, answer.draft, answer.box, agent));
},
[agent, agentStatus, answerPanePrompt, answering, heldByOpenQueue, submitComposerMessage, queueStore, machineId, settings.autoSendQueued],
);
Expand Down Expand Up @@ -2887,6 +2887,7 @@ export function PaneTerminal({
messages={pending.map((message) => !message.serverOwned || pendingMessages.isOwned(queueOwner, message.id, pendingScopeRef.current)
? message : { ...message, serverOwned: false, state: "uncertain" as const })}
connected={connected}
agent={agent}
blocked={held || secretActive || ended || heldByOpenQueue || answering !== null}
unsaved={pendingMessages.isUnsaved(queueOwner)}
isBusy={(id) => pendingMessages.isBusy(queueOwner, id)}
Expand Down
Loading
Loading