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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 18 additions & 0 deletions docs/releases/0.18.3.md
Original file line number Diff line number Diff line change
@@ -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
```
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
23 changes: 12 additions & 11 deletions src/cli/chat-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
16 changes: 15 additions & 1 deletion src/cli/chat-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -722,6 +722,20 @@ function roomHeaderRows(input: Pick<ChatScreenInput, "rows" | "room_path">): num
return input.room_path && input.rows >= 6 ? 1 : 0;
}

export function chatWheelRegion(
input: Pick<ChatScreenInput, "columns" | "rows" | "draft" | "room_path">,
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 · " : "";
Expand Down
12 changes: 12 additions & 0 deletions src/cli/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getChatCompletions,
formatChatHelp,
chatTranscriptHeight,
chatWheelRegion,
CHAT_COMMANDS,
type ChatFrame
} from "./chat-view.js";
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 24 additions & 13 deletions tests/chat-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -18,6 +18,7 @@ function setup(menu = false) {
onSubmit: submit,
onQuit: quit,
onScroll: scroll,
onWheel: wheel,
onBottom: bottom,
onChange: vi.fn(),
onClear: vi.fn(),
Expand Down Expand Up @@ -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(); }
});
17 changes: 17 additions & 0 deletions tests/chat-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
}
}
});
Loading