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.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
and existing Alt combinations remain intact. ([#508](https://github.com/devswha/herdr-web-ui/pull/508))

### Changed
- The terminal's **Input held until the terminal is ready** notice appears only when there is
text to send or discard, or when text was too long to hold. It no longer counts "special
keys dropped": the count rose without a key being pressed, with every answer the terminal
gives a program by itself, and with nothing held the notice had only a dash to show. Text
past the 1,024 characters it holds is now told as left out, where it only raised that count.
([#561](https://github.com/devswha/herdr-web-ui/pull/561))
- The mic button shows in a desktop's chat without being turned on. **Settings → Voice input →
Microphone button** is now **Auto**, **On** or **Off**. Auto, the default, puts the mic in the
chat's message box on a desktop, and only where dictation can work: over HTTPS, with an OpenAI
Expand Down
7 changes: 5 additions & 2 deletions docs/terminal-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ path; each agent controls when it consumes that input. It is not a native app-se

Direct input arriving before readiness or during a disconnect is held for explicit Send/Discard.
An IME may commit several code points at once, so printable chunks (including emoji) are retained.
Control sequences are counted as discarded, never saved for later execution. No draft is replayed
on reconnect. The input-line and chat Send buttons preserve an active composition, and the key bar
Control sequences are left out, never saved for later execution, and not counted: xterm's own
answers to a program (cursor position, focus, mouse) arrive the same way. Send transmits the held
text alone, without an Enter typed meanwhile. The draft holds 1,024 characters; text past that is
left out whole and the notice says some input was left out, also when nothing else is held. No
draft is replayed on reconnect. The input-line and chat Send buttons preserve an active composition, and the key bar
waits for composition to finish. Leaving the input clears its composition guard.

The key bar defaults to Esc, Tab, Ctrl, Alt, Shift, Enter, the arrows and ^C.
Expand Down
2 changes: 1 addition & 1 deletion src/components/PaneTerminal.css
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@
color: var(--text);
}

.terminal-banner-draft .draft-dropped {
.terminal-banner-draft .draft-truncated {
color: var(--text-dim);
}

Expand Down
12 changes: 6 additions & 6 deletions src/components/PaneTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1283,7 +1283,8 @@ export function PaneTerminal({
let saved = EMPTY_DRAFT;
try {
const value = paneId ? JSON.parse(localStorage.getItem(`herdr-web-ui:terminal-draft:${paneStorageId(machineId, paneId)}`) ?? "null") : null;
if (value && typeof value.text === "string" && Number.isInteger(value.droppedSpecial)) saved = value;
// a draft stored by an earlier version counts keys that were left out instead: only its text is kept
if (value && typeof value.text === "string") saved = { text: value.text, truncated: value.truncated === true };
} catch {}
setDraft(saved);
draftPaneRef.current = paneId;
Expand Down Expand Up @@ -1685,16 +1686,15 @@ export function PaneTerminal({
{paneId !== null && !chatView && !ended && !connected && (
<div className="terminal-banner terminal-banner-warning" role="status">
reconnecting to herdr web ui…
{!draftIsEmpty(draft) && <span className="draft-held"> input held: “{draft.text}”</span>}
{draft.text.length > 0 && <span className="draft-held"> input held: “{draft.text}”</span>}
</div>
)}
{paneId !== null && !ended && connected && !draftIsEmpty(draft) && (
<div className="terminal-banner terminal-banner-draft" role="status">
<span className="draft-label">{t("Input held until the terminal is ready:")}</span>
<code className="draft-text">{draft.text.length > 0 ? draft.text : "—"}</code>
{draft.droppedSpecial > 0 && (
<span className="draft-dropped">{t(draft.droppedSpecial === 1 ? "{count} special key dropped" : "{count} special keys dropped", { count: draft.droppedSpecial })}</span>
)}
{draft.text.length > 0 && <code className="draft-text">{draft.text}</code>}
{/* the preview shows the start of the text: without this, a draft that lost its end looks whole */}
{draft.truncated && <span className="draft-truncated">{t("Some input was too long to hold and was left out.")}</span>}
<span className="draft-actions">
<button type="button" className="draft-send" disabled={draft.text.length === 0 || observing || secretActive || held} onClick={sendDraft}>
{t("Send")}
Expand Down
55 changes: 38 additions & 17 deletions src/lib/draft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,68 @@ import { applyToDraft, draftIsEmpty, EMPTY_DRAFT } from "./draft.ts";

describe("applyToDraft", () => {
it("appends a printable character to the draft", () => {
expect(applyToDraft(EMPTY_DRAFT, "a")).toEqual({ text: "a", droppedSpecial: 0 });
expect(applyToDraft(EMPTY_DRAFT, "a")).toEqual({ text: "a", truncated: false });
});

it("keeps the order of consecutive typed characters", () => {
const draft = applyToDraft(applyToDraft(EMPTY_DRAFT, "l"), "s");
expect(draft.text).toBe("ls");
});

it("counts a special key instead of drafting it", () => {
it("leaves a special key out of the draft", () => {
const draft = applyToDraft(applyToDraft(EMPTY_DRAFT, "x"), "\r");
expect(draft).toEqual({ text: "x", droppedSpecial: 1 });
expect(draft).toEqual({ text: "x", truncated: false });
});

it("counts escape sequences, arrows and control codes as special keys", () => {
for (const special of ["\u001b[A", "\u0003", "\u001b", "\t"]) {
expect(applyToDraft(EMPTY_DRAFT, special).droppedSpecial).toBe(1);
it("holds nothing for escape sequences, arrows, control codes and DEL: there is nothing to send or to tell", () => {
// also what xterm answers a program by itself: a cursor position, a focus or a mouse report
for (const special of ["\u001b[A", "\u0003", "\u001b", "\t", "\u007f", "\u001b[12;40R", "\u001b[I", "\u001b[<35;10;5M"]) {
const draft = applyToDraft(EMPTY_DRAFT, special);
expect(draft).toBe(EMPTY_DRAFT);
expect(draftIsEmpty(draft)).toBe(true);
}
});

it("caps the draft instead of growing without bound", () => {
it("caps the draft instead of growing without bound, and says that text was left out", () => {
let draft = EMPTY_DRAFT;
for (let i = 0; i < 1100; i += 1) draft = applyToDraft(draft, "x");
expect(draft.text.length).toBe(1024);
expect(draft.droppedSpecial).toBe(1100 - 1024);
for (let i = 0; i < 1024; i += 1) draft = applyToDraft(draft, "x");
expect(draft).toEqual({ text: "x".repeat(1024), truncated: false });
for (let i = 0; i < 76; i += 1) draft = applyToDraft(draft, "y");
expect(draft).toEqual({ text: "x".repeat(1024), truncated: true });
});

it("treats DEL and non-printable single characters as special", () => {
expect(applyToDraft(EMPTY_DRAFT, "\u007f").droppedSpecial).toBe(1);
it("leaves out whole a chunk the draft has no room for, and says so even with nothing held", () => {
// a paste that arrives as one chunk: half of a command is never held
const pasted = applyToDraft(EMPTY_DRAFT, "x".repeat(1025));
expect(pasted).toEqual({ text: "", truncated: true });
expect(draftIsEmpty(pasted)).toBe(false);
const after = applyToDraft(applyToDraft(EMPTY_DRAFT, "ls "), "x".repeat(1022));
expect(after).toEqual({ text: "ls ", truncated: true });
// what still fits is held, and the loss stays told
expect(applyToDraft(after, "-la")).toEqual({ text: "ls -la", truncated: true });
});

it("keeps a loss told when a special key follows it", () => {
const lost = applyToDraft(EMPTY_DRAFT, "x".repeat(1025));
expect(applyToDraft(lost, "\r")).toBe(lost);
});
});

describe("draftIsEmpty", () => {
it("is true for the empty draft and false once anything is held", () => {
it("is true for the empty draft and false once text is held or was left out", () => {
expect(draftIsEmpty(EMPTY_DRAFT)).toBe(true);
expect(draftIsEmpty({ text: "", droppedSpecial: 1 })).toBe(false);
expect(draftIsEmpty({ text: "a", droppedSpecial: 0 })).toBe(false);
expect(draftIsEmpty({ text: "a", truncated: false })).toBe(false);
expect(draftIsEmpty({ text: "", truncated: true })).toBe(false);
});
});

it("holds multi-codepoint IME commits without splitting a surrogate at the limit", () => {
for (const text of ["한글", "😀", "e\u0301", "abc"]) expect(applyToDraft(EMPTY_DRAFT, text).text).toBe(text);
expect(applyToDraft({ text: "a".repeat(1023), droppedSpecial: 0 }, "😀").text).toHaveLength(1023);
expect(applyToDraft(EMPTY_DRAFT, "\x1b[200~text\x1b[201~").text).toBe("");
expect(applyToDraft({ text: "a".repeat(1023), truncated: false }, "😀")).toEqual({ text: "a".repeat(1023), truncated: true });
// a paste is held by its text, not lost with its frames; one of several lines holds controls and is not
expect(applyToDraft(EMPTY_DRAFT, "\x1b[200~text\x1b[201~")).toEqual({ text: "text", truncated: false });
expect(applyToDraft(EMPTY_DRAFT, "\x1b[200~one\rtwo\x1b[201~")).toEqual(EMPTY_DRAFT);
expect(applyToDraft(EMPTY_DRAFT, `\x1b[200~${"a".repeat(1025)}\x1b[201~`)).toEqual({ text: "", truncated: true });
// a frame that never closes is a control chunk still
expect(applyToDraft(EMPTY_DRAFT, "\x1b[200~text")).toEqual(EMPTY_DRAFT);
});
33 changes: 20 additions & 13 deletions src/lib/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@

export interface InputDraft {
readonly text: string;
/** special keys (Enter, arrows, ^C, ...) received while disconnected - undraftable, counted */
readonly droppedSpecial: number;
/** text was typed or pasted that the draft had no room for: what is held is not all of it */
readonly truncated: boolean;
}

export const EMPTY_DRAFT: InputDraft = { text: "", droppedSpecial: 0 };
export const EMPTY_DRAFT: InputDraft = { text: "", truncated: false };

const MAX_DRAFT_CHARS = 1024;

Expand All @@ -19,18 +19,25 @@ function isPrintableChar(data: string): boolean {
return data.length > 0 && !/[\x00-\x1f\x7f-\x9f]/u.test(data);
}

/** Folds one onData chunk into the draft: printable text accumulates, special keys count. */
/** xterm's bracketed paste: the pasted text between the frames, which is what the user gave. */
const BRACKETED_PASTE = /^\x1b\[200~([\s\S]*)\x1b\[201~$/;

/**
* Folds one onData chunk into the draft: printable text accumulates, a paste by the text
* inside its bracketed-paste frames. A chunk with controls is left out and not told: Enter,
* arrows and a paste of several lines contain them, and so do the answers xterm gives a
* program by itself (cursor position, focus, mouse), which no one typed. Text the draft has
* no room for is left out whole, never cut in the middle of what was typed, and the draft
* says so.
*/
export function applyToDraft(draft: InputDraft, data: string): InputDraft {
if (!isPrintableChar(data)) {
// Enter, arrows and bracketed paste frames contain controls; keep only plain text.
return { ...draft, droppedSpecial: draft.droppedSpecial + 1 };
}
if (draft.text.length + data.length > MAX_DRAFT_CHARS) {
return { ...draft, droppedSpecial: draft.droppedSpecial + 1 };
}
return { ...draft, text: draft.text + data };
const text = BRACKETED_PASTE.exec(data)?.[1] ?? data;
if (!isPrintableChar(text)) return draft;
if (draft.text.length + text.length > MAX_DRAFT_CHARS) return draft.truncated ? draft : { ...draft, truncated: true };
return { ...draft, text: draft.text + text };
}

/** Nothing held and nothing to tell. A draft that only lost text is not empty: its loss is still to be told. */
export function draftIsEmpty(draft: InputDraft): boolean {
return draft.text.length === 0 && draft.droppedSpecial === 0;
return draft.text.length === 0 && !draft.truncated;
}
3 changes: 1 addition & 2 deletions src/lib/i18n.ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,8 +528,7 @@ export const JA: Record<string, string> = {
"Open here": "ここで開く",
"Take this pane from another web app or terminal attach. That connection will close.": "ほかのウェブアプリや terminal attach からこのペインを引き継ぎます。その接続は終了します。",
"Input held until the terminal is ready:": "端末の準備ができるまで保持した入力:",
"{count} special key dropped": "特殊キー {count} 個を破棄しました",
"{count} special keys dropped": "特殊キー {count} 個を破棄しました",
"Some input was too long to hold and was left out.": "入力が長すぎたため、一部は保持されませんでした。",
"Send": "送信",
"Discard": "破棄",
"Held message — review and send": "保持中のメッセージ。確認して送信してください",
Expand Down
3 changes: 1 addition & 2 deletions src/lib/i18n.ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,8 +526,7 @@ export const KO: Record<string, string> = {
"Open here": "여기서 열기",
"Take this pane from another web app or terminal attach. That connection will close.": "다른 웹 앱이나 terminal attach에서 이 패널을 가져옵니다. 그쪽 연결은 종료됩니다.",
"Input held until the terminal is ready:": "터미널 입력 준비까지 보관한 내용:",
"{count} special key dropped": "특수 키 {count}개 버려짐",
"{count} special keys dropped": "특수 키 {count}개 버려짐",
"Some input was too long to hold and was left out.": "입력이 너무 길어 일부는 보관하지 못했습니다.",
"Send": "보내기",
"Discard": "버리기",
"Held message — review and send": "보관된 메시지. 확인하고 보내세요",
Expand Down
3 changes: 1 addition & 2 deletions src/lib/i18n.zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,8 +530,7 @@ export const ZH: Record<string, string> = {
"Open here": "在此打开",
"Take this pane from another web app or terminal attach. That connection will close.": "从另一个网页应用或 terminal attach 接管此窗格。那边的连接将关闭。",
"Input held until the terminal is ready:": "终端就绪前保留的输入:",
"{count} special key dropped": "已丢弃 {count} 个特殊按键",
"{count} special keys dropped": "已丢弃 {count} 个特殊按键",
"Some input was too long to hold and was left out.": "输入过长,部分内容未能保留。",
"Send": "发送",
"Discard": "丢弃",
"Held message — review and send": "保留的消息。请检查后发送",
Expand Down