Skip to content
Closed
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
## [Unreleased]

### Added
- **Settings → Chat → Autocorrect** lets the device keyboard correct, suggest and capitalize
sentences in the message box and queued messages, so phones get autocorrect and suggestions
in chat. Off by default; the terminal input line is unchanged.
- **Settings → Phone & devices → Portal** gives this PC a public HTTPS address through
[Portal](https://github.com/gosuda/portal-tunnel), with no account and no domain of your own.
With Portal v2.6.1 or later installed, **Start** runs it on a relay you enter, dropping any
Expand Down
5 changes: 4 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,9 @@ One set for both themes: the card is island black wherever it shows.
is ignored. Stop sends Escape. While the agent works and a draft shows Send in Stop's place,
Escape in the box is Stop; an open completion menu takes the first Escape. Working Send
schedules a pending message; its Send now action submits that same message now.
- The message box and queued messages turn off the keyboard's spell check, capitals and autocorrect,
since commands, paths and code go through them; **Autocorrect** (Settings → Chat, this device) turns
all three on. The terminal input line keeps its own attributes.

### Voice input
- A mic button sits beside the add button in the composer and beside Send in the terminal input line; it
Expand Down Expand Up @@ -1225,7 +1228,7 @@ One set for both themes: the card is island black wherever it shows.
replaces the one entry. A reload steps out of the entries it finds; Forward reopens the page.
- Pages, in order: **Appearance** (theme, colors, density, language, sidebar rows), **Chat**
(panes open in, show thinking, chat width, chat font size and family, highlight code; then
**Composer**: optional plain PageUp/PageDown chat scrolling (off by default), Enter sends, files at once, suggestion chip; then **Quick replies**; then **File viewer**: wrap long
**Composer**: optional plain PageUp/PageDown chat scrolling (off by default), Enter sends, autocorrect, files at once, suggestion chip; then **Quick replies**; then **File viewer**: wrap long
lines, Markdown width), **Terminal** (font size, 80 columns in a split, font family, wheel
speed, input mode, Key bar), **Alerts**, **Voice input**, **Subscription usage**,
**Shortcuts** (the platform-resolved global bindings, then **Paging** with a 0–100 line overlap stepper; one line by default, zero for a full viewport), **Phone & devices** (the phone address,
Expand Down
3 changes: 3 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,9 @@ the screen. Shift+PageUp/PageDown alone retains the focused control's own behavi

Enter sends and Shift+Enter adds a line. Settings can switch sending to Mod+Enter.

The message box leaves the keyboard's autocorrect, suggestions and capitals off, so commands
and paths go as typed. Turn on **Settings → Chat → Autocorrect** to type prose with them on this device.

## How it works

```mermaid
Expand Down
19 changes: 19 additions & 0 deletions scripts/ui-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,25 @@ try {
await setAttachLimit(2, 20);
console.log("PASS one pick attaches up to the chosen number of files and names what it left out");

// Settings → Chat → Autocorrect hands the message box to the keyboard's corrections; off, it keeps them away.
const keyboardAttributes = () => composer.evaluate((element: HTMLTextAreaElement) =>
[element.getAttribute("spellcheck"), element.getAttribute("autocapitalize"), element.getAttribute("autocorrect")]);
const setAutocorrect = async (on: boolean) => {
await page.keyboard.press("ControlOrMeta+Shift+Comma");
await openSettingsPage(page, "Chat");
const toggle = page.getByRole("switch", { name: "Autocorrect", exact: true });
assert.equal(await toggle.getAttribute("aria-checked"), String(!on));
await toggle.click();
assert.equal(await toggle.getAttribute("aria-checked"), String(on));
await page.getByRole("button", { name: "Close settings", exact: true }).click();
};
assert.deepEqual(await keyboardAttributes(), ["false", "off", "off"], "the keyboard's corrections are off by default");
await setAutocorrect(true);
assert.deepEqual(await keyboardAttributes(), ["true", "sentences", "on"], "Autocorrect turns the keyboard's corrections on");
await setAutocorrect(false);
assert.deepEqual(await keyboardAttributes(), ["false", "off", "off"], "turning it off takes them away again");
console.log("PASS Settings → Chat → Autocorrect turns the message box's keyboard corrections on and off");

let releaseCreate!: () => void;
const createGate = new Promise<void>((resolve) => { releaseCreate = resolve; });
releases.push(releaseCreate);
Expand Down
3 changes: 3 additions & 0 deletions shared/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export interface TelemetrySettings {
chatPageKeys: boolean;
/** files one paste, pick or drop may attach (#863) */
attachLimit: number;
/** the device keyboard's corrections in the composer (#753) */
chatAutocorrect: boolean;
}

/** The browser's full, non-sensitive snapshot. */
Expand Down Expand Up @@ -156,6 +158,7 @@ const TELEMETRY_SETTINGS_VALIDATORS = {
pageOverlapRows: integerBetween(0, 100),
chatPageKeys: booleanValue,
attachLimit: integerBetween(1, 50),
chatAutocorrect: booleanValue,
} satisfies Record<keyof TelemetrySettings, (value: unknown) => boolean>;

const TELEMETRY_SETTINGS_KEYS = Object.keys(TELEMETRY_SETTINGS_VALIDATORS) as (keyof TelemetrySettings)[];
Expand Down
5 changes: 2 additions & 3 deletions src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { composerDrafts } from "../lib/composerDraft.ts";
import { paneStorageId } from "../../shared/machines.ts";
import {
agentDisplayLabel,
composerKeyboard,
composerModelDraw,
composerSendShown,
composerStatusCompact,
Expand Down Expand Up @@ -1070,9 +1071,7 @@ export function Composer({
aria-controls={menuOpen ? menuId : undefined}
aria-expanded={menuOpen}
aria-activedescendant={menuOpen ? `${menuId}-${selectedIndex}` : undefined}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
{...composerKeyboard(settings.chatAutocorrect)}
// stays editable while the socket reconnects (sending waits for it): a phone's
// dictation keyboard opens its own app and comes back, the socket may drop meanwhile,
// and a disabled box would lose its focus and the dictated text with it
Expand Down
4 changes: 2 additions & 2 deletions src/components/PaneTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { EMPTY_DRAFT, applyToDraft, draftIsEmpty, restoreDraft, type InputDraft
import { messageQueues } from "../lib/messageQueue.ts";
import { pendingMessages } from "../lib/pendingMessages.ts";
import { heldCountShown, heldOpenAtFold, heldOpenOnFocus, heldRefocusDue, heldRowError, heldRowsFold, heldRowsHidden, heldToggleShown, SHORT_PHONE_QUERY } from "../lib/heldRows.ts";
import { type DraftRefusal, MAX_COMPOSER_CHARS, QUEUE_READY_STATUS, agentDisplayLabel, composerDelivery, composerMessage, composerPayload, submitNote, submitNotTyped, submitRefusal } from "../lib/compose.ts";
import { type DraftRefusal, MAX_COMPOSER_CHARS, QUEUE_READY_STATUS, agentDisplayLabel, composerDelivery, composerKeyboard, composerMessage, composerPayload, submitNote, submitNotTyped, submitRefusal } from "../lib/compose.ts";
import { afterRead, afterSend, afterSettled, composerLift, greetingMemory, rememberGreeting, greetingFits, greetingFolder, roomOverComposer, showsGreeting, type ChatRead } from "../lib/greeting.ts";
import { answerFromText, answerHint, answerRefusal, needsConfirmation, type TypedAnswer } from "../lib/promptAnswer.ts";
import { ApiError, assertAttachable, fetchPaneScroll, fetchPaneSelection, scrollPane } from "../lib/api.ts";
Expand Down Expand Up @@ -2439,7 +2439,7 @@ export function PaneTerminal({
aria-label={t("Queued message {n}", { n: index + 1 })}
maxLength={MAX_COMPOSER_CHARS}
disabled={queueStore.isSending(message.id)}
spellCheck={false} autoCapitalize="off" autoCorrect="off"
{...composerKeyboard(settings.chatAutocorrect)}
onChange={(event) => { queueStore.edit(queueOwner, message.id, event.target.value); }}
/>
<div className="composer-queue-actions">
Expand Down
3 changes: 3 additions & 0 deletions src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ function ChatPage() {
<SettingsRow label={t("Enter sends")} description={t("When off, Mod+Enter sends")}>
<Toggle label={t("Enter sends")} checked={settings.enterSends} onChange={(enterSends) => update({ enterSends })} />
</SettingsRow>
<SettingsRow label={t("Autocorrect")} description={t("The keyboard corrects, suggests and capitalizes what you type in the message box, on this device. Off, text stays exactly as typed.")}>
<Toggle label={t("Autocorrect")} checked={settings.chatAutocorrect} onChange={(chatAutocorrect) => update({ chatAutocorrect })} />
</SettingsRow>
<SettingsRow label={t("Files at once")} description={t("How many files one paste, pick or drop attaches. The rest are left out, and the message box says how many.")}>
<Stepper label={t("Files at once")} value={settings.attachLimit} text={String(settings.attachLimit)} min={ATTACH_LIMIT_MIN} max={ATTACH_LIMIT_MAX} decreaseLabel={t("Attach fewer files at once")} increaseLabel={t("Attach more files at once")} onChange={(attachLimit) => update({ attachLimit })} />
</SettingsRow>
Expand Down
2 changes: 1 addition & 1 deletion src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ it("posts the full safe telemetry snapshot and current notice version", async ()
const body = JSON.parse(String(requestInit?.body)) as Record<string, unknown>;
expect(body).toEqual(change);
expect(body).not.toHaveProperty("changed_settings");
expect(Object.keys(body.settings as object)).toHaveLength(53);
expect(Object.keys(body.settings as object)).toHaveLength(54);
expect(body.settings).not.toHaveProperty("terminalFontFamily");
expect(body.settings).not.toHaveProperty("chatFontFamily");
expect(body.settings).not.toHaveProperty("usageHidden");
Expand Down
9 changes: 8 additions & 1 deletion src/lib/compose.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";

import { agentDisplayLabel, composerDelivery, composerMessage, terminalOnlyCommand, composerSendShown, composerPayload, composerModelDraw, composerStatusCompact, composerStatusHint, composerStatusWord, COMPOSER_STATUS_COMPACT_BELOW, contextLeftPercent, formatTokens, imageMention, insertLeadingMention, MAX_COMPOSER_CHARS, QUEUE_READY_STATUS, rankSlashCommands, submitNote, submitNotTyped, submitRefusal, sameMessage } from "./compose.ts";
import { agentDisplayLabel, composerDelivery, composerKeyboard, composerMessage, terminalOnlyCommand, composerSendShown, composerPayload, composerModelDraw, composerStatusCompact, composerStatusHint, composerStatusWord, COMPOSER_STATUS_COMPACT_BELOW, contextLeftPercent, formatTokens, imageMention, insertLeadingMention, MAX_COMPOSER_CHARS, QUEUE_READY_STATUS, rankSlashCommands, submitNote, submitNotTyped, submitRefusal, sameMessage } from "./compose.ts";

describe("composerMessage and submitNote", () => {
it("keeps the message as written for agent.prompt: inner newlines stay, the composer's own trailing ones go", () => {
Expand Down Expand Up @@ -292,3 +292,10 @@ describe("commands the chat cannot finish", () => {
expect(terminalOnlyCommand(null, "/tree")).toBeNull();
});
});

describe("composerKeyboard", () => {
it("keeps the keyboard's corrections off unless the setting turns them on", () => {
expect(composerKeyboard(false)).toEqual({ spellCheck: false, autoCapitalize: "off", autoCorrect: "off" });
expect(composerKeyboard(true)).toEqual({ spellCheck: true, autoCapitalize: "sentences", autoCorrect: "on" });
});
});
8 changes: 8 additions & 0 deletions src/lib/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ export function composerMessage(text: string): string {
return text.replace(/[\r\n]+$/, "").replace(/\r\n?/g, "\n");
}

/** The message box's keyboard attributes: off by default, since commands, paths and code go through the same box;
* on, the device keyboard corrects, suggests and capitalizes sentences (Settings → Chat → Autocorrect). */
export function composerKeyboard(autocorrect: boolean): { spellCheck: boolean; autoCapitalize: "sentences" | "off"; autoCorrect: "on" | "off" } {
return autocorrect
? { spellCheck: true, autoCapitalize: "sentences", autoCorrect: "on" }
: { spellCheck: false, autoCapitalize: "off", autoCorrect: "off" };
}

/** The text a composer message types, without its submit (HerdrSocket.submit adds the Enter). */
export function composerPayload(text: string, bracketedPaste: boolean): string {
const body = composerMessage(text).replace(/\n/g, "\r");
Expand Down
2 changes: 2 additions & 0 deletions src/lib/i18n.ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export const JA: Record<string, string> = {
"Composer": "入力欄",
"Enter sends": "Enter で送信",
"When off, Mod+Enter sends": "オフの場合は Mod+Enter で送信します",
"Autocorrect": "自動修正",
"The keyboard corrects, suggests and capitalizes what you type in the message box, on this device. Off, text stays exactly as typed.": "この端末で、メッセージ欄に入力した内容をキーボードが修正・予測し、文頭を大文字にします。オフの場合は入力したとおりのままです。",
"Files at once": "一度に添付するファイル数",
"How many files one paste, pick or drop attaches. The rest are left out, and the message box says how many.": "1 回の貼り付け、選択、ドロップで添付するファイルの数です。残りは添付されず、何件かをメッセージ欄に表示します。",
"Attach fewer files at once": "一度に添付するファイルを減らす",
Expand Down
2 changes: 2 additions & 0 deletions src/lib/i18n.ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export const KO: Record<string, string> = {
"Composer": "입력창",
"Enter sends": "Enter로 보내기",
"When off, Mod+Enter sends": "끄면 Mod+Enter로 보냅니다",
"Autocorrect": "자동 수정",
"The keyboard corrects, suggests and capitalizes what you type in the message box, on this device. Off, text stays exactly as typed.": "이 기기에서 메시지 상자에 입력하는 내용을 키보드가 고치고, 제안하고, 문장 첫 글자를 대문자로 바꿉니다. 끄면 입력한 그대로 남습니다.",
"Files at once": "한 번에 첨부할 파일 수",
"How many files one paste, pick or drop attaches. The rest are left out, and the message box says how many.": "붙여넣기, 선택 또는 끌어놓기 한 번에 첨부되는 파일 수입니다. 나머지는 제외되며 메시지 입력창에 몇 개인지 표시됩니다.",
"Attach fewer files at once": "한 번에 첨부할 파일 줄이기",
Expand Down
2 changes: 2 additions & 0 deletions src/lib/i18n.tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export const TR: Record<string, string> = {
"Composer": "Mesaj yazma alanı",
"Enter sends": "Enter gönderir",
"When off, Mod+Enter sends": "Kapalıyken Mod+Enter gönderir",
"Autocorrect": "Otomatik düzeltme",
"The keyboard corrects, suggests and capitalizes what you type in the message box, on this device. Off, text stays exactly as typed.": "Bu cihazda klavye, mesaj kutusuna yazdıklarınızı düzeltir, öneride bulunur ve cümle başlarını büyük harf yapar. Kapalıyken metin tam yazıldığı gibi kalır.",
"Files at once": "Tek seferde dosya",
"How many files one paste, pick or drop attaches. The rest are left out, and the message box says how many.": "Bir yapıştırma, seçim veya bırakmanın eklediği dosya sayısı. Kalanlar eklenmez ve mesaj kutusu kaç tane olduğunu söyler.",
"Attach fewer files at once": "Tek seferde daha az dosya ekle",
Expand Down
2 changes: 2 additions & 0 deletions src/lib/i18n.zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export const ZH: Record<string, string> = {
"Composer": "输入框",
"Enter sends": "按 Enter 发送",
"When off, Mod+Enter sends": "关闭后按 Mod+Enter 发送",
"Autocorrect": "自动更正",
"The keyboard corrects, suggests and capitalizes what you type in the message box, on this device. Off, text stays exactly as typed.": "在此设备上,键盘会更正、联想你在消息框中输入的内容,并将句首字母大写。关闭后,文字保持输入时的原样。",
"Files at once": "每次附加的文件数",
"How many files one paste, pick or drop attaches. The rest are left out, and the message box says how many.": "一次粘贴、选择或拖放附加的文件数。其余文件不会附加,消息框会显示数量。",
"Attach fewer files at once": "每次附加更少文件",
Expand Down
9 changes: 9 additions & 0 deletions src/lib/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,3 +755,12 @@ it("keeps plain chat paging opt-in and saves the keyboard choice on this device"
}
for (const chatPageKeys of [null, "true", 1]) expect(sanitizeSettings({ chatPageKeys }).chatPageKeys).toBe(false);
});

it("keeps the composer's keyboard corrections off until this device turns them on", () => {
expect(DEFAULT_SETTINGS.chatAutocorrect).toBe(false);
expect(SETTING_SCOPE.chatAutocorrect).toBe("device");
for (const chatAutocorrect of [true, false]) {
expect(sanitizeSettings(JSON.parse(JSON.stringify(sanitizeSettings({ chatAutocorrect })))).chatAutocorrect).toBe(chatAutocorrect);
}
for (const chatAutocorrect of [null, "true", 1]) expect(sanitizeSettings({ chatAutocorrect }).chatAutocorrect).toBe(false);
});
8 changes: 7 additions & 1 deletion src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ export interface Settings {
chatWidth: ChatWidth;
/** true: Enter sends in the composer, Shift+Enter breaks the line; false: Ctrl/Cmd+Enter sends */
enterSends: boolean;
/** the device keyboard's autocorrect, suggestions, spell check and sentence capitals in the composer and queued
* messages (lib/compose.ts composerKeyboard); off by default, as commands and paths go through the same box */
chatAutocorrect: boolean;
/** how many files one paste, pick or drop attaches; the rest are left out with a note */
attachLimit: number;
/** true: a message sent while the agent works goes by itself when the turn ends; false: it waits for Send now */
Expand Down Expand Up @@ -237,6 +240,7 @@ export function telemetrySettingsSnapshot(settings: Settings): TelemetrySettings
pageOverlapRows: settings.pageOverlapRows,
chatPageKeys: settings.chatPageKeys,
attachLimit: settings.attachLimit,
chatAutocorrect: settings.chatAutocorrect,
};
}

Expand All @@ -253,7 +257,7 @@ export const SETTING_SCOPE = {
terminalInputMode: "device", keyBarExtras: "device", keyBarItems: "device", shortcutOverrides: "device", pageOverlapRows: "device", chatPageKeys: "device", density: "device",
quietOpenedDone: "device", terminalFontSize: "device", terminalCursorStyle: "device", terminalCursorBlink: "device",
terminalWheelSpeed: "device", terminalFontFamily: "device", paneClipboard: "device", releasePaneAway: "device",
narrowPaneFont: "device", chatFontSize: "device", chatFontFamily: "device", chatWidth: "device", enterSends: "device", attachLimit: "device",
narrowPaneFont: "device", chatFontSize: "device", chatFontFamily: "device", chatWidth: "device", enterSends: "device", chatAutocorrect: "device", attachLimit: "device",
keepScreenOn: "device", alertsOn: "device", alertInput: "device", alertDone: "device", alertInApp: "device", alertSound: "device",
voiceInput: "device", voiceLanguage: "device", voicePolishChat: "device", voicePolishTerminal: "device",
// Viewer layout follows this screen, as chatWidth does; highlighting remains shared.
Expand Down Expand Up @@ -293,6 +297,7 @@ export const DEFAULT_SETTINGS: Settings = {
chatFontFamily: "",
chatWidth: "default",
enterSends: true,
chatAutocorrect: false,
attachLimit: 20,
autoSendQueued: true,
showThinking: false,
Expand Down Expand Up @@ -466,6 +471,7 @@ export function sanitizeSettings(raw: unknown): Settings {
chatFontFamily: sanitizeFontFamily(record["chatFontFamily"]),
chatWidth: CHAT_WIDTHS.includes(record["chatWidth"] as ChatWidth) ? record["chatWidth"] as ChatWidth : DEFAULT_SETTINGS.chatWidth,
enterSends: typeof record["enterSends"] === "boolean" ? record["enterSends"] : DEFAULT_SETTINGS.enterSends,
chatAutocorrect: typeof record["chatAutocorrect"] === "boolean" ? record["chatAutocorrect"] : DEFAULT_SETTINGS.chatAutocorrect,
attachLimit: typeof record["attachLimit"] === "number" && Number.isFinite(record["attachLimit"])
? Math.min(ATTACH_LIMIT_MAX, Math.max(ATTACH_LIMIT_MIN, Math.round(record["attachLimit"])))
: DEFAULT_SETTINGS.attachLimit,
Expand Down
1 change: 1 addition & 0 deletions telemetry/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const settings: TelemetrySettings = {
pageOverlapRows: 1,
chatPageKeys: false,
attachLimit: 20,
chatAutocorrect: false,
};
const now = new Date("2026-10-08T13:45:00Z");

Expand Down