diff --git a/CHANGELOG.md b/CHANGELOG.md index 909bc67..1ad7937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ changes will be called out under **Breaking changes**. ## Unreleased +## [0.18.3] — 2026-09-16 + +Full notes: [`docs/releases/0.18.3.md`](docs/releases/0.18.3.md). + +### Fixed + +- Restore Up/Down prompt history at an empty draft. In `--mouse` mode, wheel events scroll conversation history over the transcript and navigate draft lines or prompt history over the composer. + ## [0.18.2] — 2026-09-16 Full notes: [`docs/releases/0.18.2.md`](docs/releases/0.18.2.md). @@ -570,6 +578,7 @@ Initial alpha. Core room protocol, SQLite-backed persistence, multi-process contention coverage, MCP smoke coverage, human guardian flow, harness installers, and the portable `talking-stick` skill. +[0.18.3]: https://github.com/mostlydev/talking-stick/releases/tag/v0.18.3 [0.18.2]: https://github.com/mostlydev/talking-stick/releases/tag/v0.18.2 [0.18.1]: https://github.com/mostlydev/talking-stick/releases/tag/v0.18.1 [0.18.0]: https://github.com/mostlydev/talking-stick/releases/tag/v0.18.0 diff --git a/README.md b/README.md index b2f1ff1..aa05cab 100644 --- a/README.md +++ b/README.md @@ -310,9 +310,9 @@ claude → you 12:05 3 members │ codex holding 12m · claude idle 3m ``` -Scroll with Page Up/Page Down or Shift+Up/Down. With an empty draft, Up/Down also scroll the conversation; this supports terminals that translate the mouse wheel into arrow keys. Use Ctrl+P/Ctrl+N to recall submitted prompts. The input stays fixed and editable. New messages do not pull you away from older history; a count appears in the footer. Ctrl+End or `/bottom` returns to live messages. The in-memory buffer retains up to 2,000 message/notice blocks and rewraps on resize. Mouse capture is off by default: drag to select text, double-click to select a word, and copy using your terminal's usual shortcut or menu. For wheel scrolling inside the conversation, opt in with `tt chat --mouse`; this captures mouse gestures, so native selection then requires your terminal's selection modifier (often Shift). `--no-mouse` explicitly restores the default and wins if both flags are supplied. +Scroll with Page Up/Page Down or Shift+Up/Down. Up/Down recall submitted prompts (or navigate suggestions and multiline drafts); Ctrl+P/Ctrl+N also recall prompts. The input stays fixed and editable. New messages do not pull you away from older history; a count appears in the footer. Ctrl+End or `/bottom` returns to live messages. The in-memory buffer retains up to 2,000 message/notice blocks and rewraps on resize. Mouse capture is off by default: drag to select text, double-click to select a word, and copy using your terminal's usual shortcut or menu. For pointer-based wheel scrolling, opt in with `tt chat --mouse`: over the conversation it scrolls history; over the prompt it navigates draft lines or prompt history; this captures mouse gestures, so native selection then requires your terminal's selection modifier (often Shift). `--no-mouse` explicitly restores the default and wins if both flags are supplied. -Typing `/`, `@`, or `!@` opens a suggestion list drawn over the bottom of the conversation, so nothing moves while you type. Up/Down choose, Tab or Enter accept, and Enter still sends once the word is complete (an exact `/quit` still quits). Escape closes the list first and clears the draft on a second press; Ctrl+C clears the draft. Neither quits. Alt+Enter (or Shift+Enter where the terminal supports it) adds a new line, and with the list closed Up/Down move through a multi-line draft at the same column. On a nonempty single-line draft, Up/Down retain prompt-history navigation; empty-draft arrows scroll the conversation. Pasted multiline text stays in the draft until Enter. On exit, the console restores the original terminal screen. +Typing `/`, `@`, or `!@` opens a suggestion list drawn over the bottom of the conversation, so nothing moves while you type. Up/Down choose, Tab or Enter accept, and Enter still sends once the word is complete (an exact `/quit` still quits). Escape closes the list first and clears the draft on a second press; Ctrl+C clears the draft. Neither quits. Alt+Enter (or Shift+Enter where the terminal supports it) adds a new line, and with the list closed Up/Down move through a multi-line draft at the same column. On a single-line or empty draft, Up/Down navigate prompt history. Pasted multiline text stays in the draft until Enter. On exit, the console restores the original terminal screen. History is split with Today, Yesterday, and date dividers. Earlier days are dimmed and their timestamps include the day. When someone joins after four quiet hours, everything before that is dimmed as an earlier conversation; this is a visual boundary, not a sign that a quiet agent has exited. diff --git a/docs/releases/0.18.3.md b/docs/releases/0.18.3.md new file mode 100644 index 0000000..f5ef7d9 --- /dev/null +++ b/docs/releases/0.18.3.md @@ -0,0 +1,18 @@ +# Talking Stick 0.18.3 + +Date: 2026-09-16 + +## Fixed + +- Restore Up/Down prompt history at an empty draft. In `--mouse` mode, wheel events scroll conversation history over the transcript and navigate draft lines or prompt history over the composer. + +## Verification + +```bash +npm run typecheck +npm test +npm run build +node dist/cli.js --help +git diff --check +npm pack --dry-run +``` diff --git a/package-lock.json b/package-lock.json index 841048a..ea1958a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "talking-stick", - "version": "0.18.2", + "version": "0.18.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "talking-stick", - "version": "0.18.2", + "version": "0.18.3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index fb59865..83eee56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "talking-stick", - "version": "0.18.2", + "version": "0.18.3", "description": "CLI coordination tool for path-scoped agent handoffs.", "type": "module", "bin": { diff --git a/src/cli/chat-input.ts b/src/cli/chat-input.ts index 0a8f1bb..11a9862 100644 --- a/src/cli/chat-input.ts +++ b/src/cli/chat-input.ts @@ -10,6 +10,7 @@ export interface ChatInputOptions { onChange: () => void; onSubmit: (line: string) => void; onScroll: (kind: "lines" | "pages", amount: number) => void; + onWheel?: (row: number, direction: number) => void; onBottom: () => void; onQuit: () => void; onClear: () => void; @@ -126,6 +127,10 @@ export class ChatInputController { this.options.onChange(); } + scrollPrompt(direction: number): void { + this.moveVertical(direction, direction < 0 ? "\u001b[A" : "\u001b[B"); + } + close(): void { if (this.closed) return; this.closed = true; @@ -176,13 +181,6 @@ export class ChatInputController { this.selectedCompletion = (this.completionIndex + direction + count) % count; return; } - // With native selection enabled, terminals may translate wheel movement - // into ordinary arrows. At an empty prompt, navigate the conversation - // instead of replacing the draft with previously submitted messages. - if (this.draft.line.length === 0) { - this.options.onScroll("lines", direction); - return; - } const moved = moveChatCursorVertical(this.draft, this.sink.columns, direction, this.verticalColumn); if (moved) { this.rl.cursor = moved.draft.cursor; @@ -242,12 +240,15 @@ export class ChatInputController { } const sequence = match[0]; this.pending = this.pending.slice(sequence.length); - const mouse = /^\u001b\[<(\d+);\d+;\d+([Mm])$/.exec(sequence); + const mouse = /^\u001b\[<(\d+);\d+;(\d+)([Mm])$/.exec(sequence); if (mouse) { - if (mouse[2] === "M") { + if (mouse[3] === "M") { const button = Number(mouse[1]); - if ((button & 195) === 64 || (button & 195) === 65) - this.options.onScroll("lines", button & 1 ? 3 : -3); + if ((button & 195) === 64 || (button & 195) === 65) { + const direction = button & 1 ? 1 : -1; + if (this.options.onWheel) this.options.onWheel(Number(mouse[2]), direction); + else this.options.onScroll("lines", direction * 3); + } } } else if (sequence === "\u001b[13;2u" || sequence === "\u001b[27;2;13~") { this.insertNewline(); diff --git a/src/cli/chat-view.ts b/src/cli/chat-view.ts index 9c5d43e..1cd28c1 100644 --- a/src/cli/chat-view.ts +++ b/src/cli/chat-view.ts @@ -62,7 +62,7 @@ export function formatChatHelp(width: number, color: boolean, keys = false): str const entries: [string, string][] = keys ? [ ["Enter", "Send; accept an incomplete suggestion first"], ["Tab", "Accept the selected suggestion"], - ["↑ / ↓", "Scroll when empty; choose suggestions or edit draft lines"], + ["↑ / ↓", "Recall prompts; choose suggestions or edit draft lines"], ["Ctrl+P / Ctrl+N", "Recall previous / next submitted prompt"], ["Alt+Enter", "Insert a new line"], ["Esc", "Dismiss suggestions; press again to clear"], @@ -722,6 +722,20 @@ function roomHeaderRows(input: Pick): num return input.room_path && input.rows >= 6 ? 1 : 0; } +export function chatWheelRegion( + input: Pick, + row: number +): "transcript" | "prompt" | null { + if (input.columns < 5 || input.rows < 4 || row < 1 || row > input.rows) return null; + const header = roomHeaderRows(input); + const transcriptEnd = header + chatTranscriptHeight(input); + if (row > header && row <= transcriptEnd) return "transcript"; + // A separator sits between the transcript and composer; the final two + // rows are the lower separator and footer. + if (row > transcriptEnd + 1 && row <= input.rows - 2) return "prompt"; + return null; +} + function roomHeader(path: string, width: number, context: ChatFormatContext): string { const safe = sanitizeChatText(path).replace(/\s+/g, " "); const prefix = width >= 16 ? "Room · " : ""; diff --git a/src/cli/chat.ts b/src/cli/chat.ts index 63383bb..08156fc 100644 --- a/src/cli/chat.ts +++ b/src/cli/chat.ts @@ -9,6 +9,7 @@ import { getChatCompletions, formatChatHelp, chatTranscriptHeight, + chatWheelRegion, CHAT_COMMANDS, type ChatFrame } from "./chat-view.js"; @@ -584,6 +585,17 @@ export async function runChatSession( ); redraw(); }, + onWheel: (row, direction) => { + const size = dimensions(); + const draft = editor?.draft ?? { line: "", cursor: 0 }; + const layout = { ...size, draft, room_path: joined.canonical_path }; + const region = chatWheelRegion(layout, row); + if (region === "prompt") editor?.scrollPrompt(direction); + else if (region === "transcript") { + transcript.scrollBy(direction * 3, chatTranscriptHeight(layout), size.columns - 1, formatContext()); + } + redraw(); + }, completionCount: (draft) => completionsFor(draft).length, complete: (draft, index) => { const matches = completionsFor(draft); diff --git a/tests/chat-input.test.ts b/tests/chat-input.test.ts index a6ccebf..5c70ff8 100644 --- a/tests/chat-input.test.ts +++ b/tests/chat-input.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest"; import { ChatInputController } from "../src/cli/chat-input.js"; import { completeChatInput, getChatCompletions } from "../src/cli/chat-view.js"; -function setup(menu = false) { +function setup(menu = false, wheel?: (row: number, direction: number) => void) { const input = Object.assign(new PassThrough(), { isRaw: false, setRawMode: vi.fn() @@ -18,6 +18,7 @@ function setup(menu = false) { onSubmit: submit, onQuit: quit, onScroll: scroll, + onWheel: wheel, onBottom: bottom, onChange: vi.fn(), onClear: vi.fn(), @@ -198,19 +199,29 @@ describe("full-screen chat input", () => { }); }); -test("empty-draft arrows scroll conversation without recalling prompts; Ctrl+P/N recall history", () => { +test("empty-draft arrows recall prompts and never use chunk boundaries to guess wheel events", () => { const { editor, input, scroll } = setup(true); try { - input.write("earlier prompt\r"); - input.write("\u001b[A\u001bOA\u001b[B\u001bOB"); - expect(editor.draft).toEqual({ line: "", cursor: 0 }); - expect(scroll.mock.calls).toEqual([["lines", -1], ["lines", -1], ["lines", 1], ["lines", 1]]); - input.write("\u0010"); - expect(editor.draft.line).toBe("earlier prompt"); - input.write("\u000e"); - expect(editor.draft.line).toBe(""); - input.write("@c\u001b[B"); - expect(editor.completionIndex).toBe(1); - expect(scroll).toHaveBeenCalledTimes(4); + input.write("first\rsecond\rthird\r"); + input.write("\u001b[A\u001b[A"); + expect(editor.draft.line).toBe("second"); + input.write("\u001bOA"); + expect(editor.draft.line).toBe("first"); + input.write("\u001b[B\u001bOB"); + expect(editor.draft.line).toBe("third"); + expect(scroll).not.toHaveBeenCalled(); + } finally { editor.close(); } +}); + +test("wheel reports retain pointer row across split input without mutating the draft", () => { + const wheel = vi.fn(); + const { editor, input, scroll } = setup(false, wheel); + try { + input.write("draft\u001b[<64;12;"); + input.write("4M\u001b[<65;12;22M\u001b[<64;12;4m"); + expect(wheel.mock.calls).toEqual([[4, -1], [22, 1]]); + expect(scroll).not.toHaveBeenCalled(); + expect(editor.draft.line).toBe("draft"); + editor.scrollPrompt(-1); } finally { editor.close(); } }); diff --git a/tests/chat-view.test.ts b/tests/chat-view.test.ts index 90d8da1..9632d65 100644 --- a/tests/chat-view.test.ts +++ b/tests/chat-view.test.ts @@ -3,6 +3,7 @@ import type { RoomEvent } from "../src/types.js"; import { buildNameResolver } from "../src/cli/chat-format.js"; import { ChatTranscript, + chatWheelRegion, completeChatInput, getChatCompletions, diffChatFrame, @@ -456,3 +457,19 @@ test("room header stays fixed through scrolling, completion and multiline editin } } }); + + +test("wheel hit testing separates transcript, composer and fixed bars after resizing", () => { + for (const rows of [6, 10, 24]) { + for (const line of ["", "a\nb\nc"]) { + const input = { room_path: "/repo", rows, columns: 40, draft: { line, cursor: line.length } }; + expect(chatWheelRegion(input, 1)).toBeNull(); + expect(chatWheelRegion(input, rows - 2)).toBe("prompt"); + expect(chatWheelRegion(input, rows - 1)).toBeNull(); + expect(chatWheelRegion(input, rows)).toBeNull(); + expect(chatWheelRegion(input, 0)).toBeNull(); + expect(chatWheelRegion(input, rows + 1)).toBeNull(); + if (rows >= 10) expect(chatWheelRegion(input, 2)).toBe("transcript"); + } + } +});