Skip to content

Commit 633e7a9

Browse files
committed
feat(ui): bound streaming render height + task nudge + clickable paths
Three things that compound to fix the user-visible scroll jank and make file-write UX feel like CC: 1. MessageList clips the in-flight streaming message to a viewport- tall tail (terminal rows minus chrome). With unbounded growth, every token added a row, log-update wrote N+1 lines, the terminal scrolled to make room, and the user's manual scrollback snapped to the bottom on every keystroke from the model. Now the streamed pane is constant- height once it fills — log-update clears N and writes N — the terminal stops fighting the scrollbar, and the user can read history while the model is still writing. Full message lands in <Static> when finalized so terminal scrollback still has the complete text. 2. System prompt now contains explicit task-list usage rules: create the plan at the start of multi-step work, exactly one task in_progress at a time, mark completed immediately, don't mark completed on errors. We had the create_task/update_task tools and the TaskPanel for a while but the model rarely used them — the prompt was the gap. 3. displayPath() wraps every file path in an OSC 8 hyperlink so terminals that support it (Ghostty, iTerm2, Kitty, recent gnome) let the user click to open the file. Terminals that don't recognise the escape strip it silently. NO_HYPERLINK=1 opts out.
1 parent 64a377f commit 633e7a9

3 files changed

Lines changed: 130 additions & 4 deletions

File tree

src/agent/system-prompt.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@ export function buildSystemPrompt(cwd: string = process.cwd()): string {
1111
"",
1212
"Be concise. Prefer code over prose. When you don't have a tool to act, say what you would do.",
1313
"",
14+
"Task checklist (create_task / update_task):",
15+
" Use the task tools to keep a visible checklist whenever the user's request needs more than 2-3 steps,",
16+
" spans multiple files or commands, or the user gave you a numbered/bulleted list. The user sees this",
17+
" list update in real time and judges progress from it.",
18+
" Skip it for single trivial actions, pure Q&A, and one-off shell commands.",
19+
" Rules:",
20+
" - Create the full plan at the start of the work, one task per intended step.",
21+
" - Provide both an imperative title ('Add OAuth refresh') and an active_form ('Adding OAuth refresh').",
22+
" - Exactly ONE task is in_progress at any time. Flip the next one to in_progress BEFORE starting it,",
23+
" and mark it completed IMMEDIATELY after it finishes — never batch completions.",
24+
" - Never mark a task completed if it errored, tests are failing, or you couldn't finish.",
25+
" Keep it in_progress and create a follow-up task for whatever's blocking.",
26+
" - If you discover work mid-task that wasn't planned, append new tasks to the list.",
27+
" - Cancel tasks that turned out to be unnecessary; don't leave stale 'pending' items.",
28+
"",
1429
"Environment:",
1530
` cwd: ${cwd}`,
1631
` platform: ${platform()}`,

src/ui/Message.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { sep as pathSep, relative as relativePath } from "node:path";
1+
import { isAbsolute, sep as pathSep, relative as relativePath, resolve as resolveAbsolute } from "node:path";
22
import type { AgentMessage } from "@earendil-works/pi-agent-core";
33
import { diffLines, diffWordsWithSpace } from "diff";
44
import { Box, Text } from "ink";
@@ -808,13 +808,35 @@ function truncate(s: string, n: number): string {
808808
*/
809809
function displayPath(p: string): string {
810810
if (!p) return p;
811+
const visible = makeRelative(p);
812+
return hyperlinkPath(visible, p);
813+
}
814+
815+
function makeRelative(p: string): string {
811816
if (!p.startsWith(pathSep)) return p; // already relative
812817
const cwd = process.cwd();
813818
const rel = relativePath(cwd, p);
814819
if (!rel || rel.startsWith("..")) return p; // outside cwd — keep absolute
815820
return rel;
816821
}
817822

823+
/**
824+
* Wrap a visible path in an OSC 8 hyperlink so terminals that support
825+
* it (Ghostty, iTerm2, Kitty, recent gnome-terminal) make file paths
826+
* clickable — click opens the file in $EDITOR / the OS default. The
827+
* escape is zero-width and well-handled by wrap-ansi for width calc.
828+
* Terminals that don't recognise OSC 8 silently strip it, so the
829+
* fallback is just "non-clickable plain text" — no visible breakage.
830+
* Opt-out: NO_HYPERLINK=1 (FORCE_HYPERLINK is honoured the other way,
831+
* matching the common npm `supports-hyperlinks` convention).
832+
*/
833+
function hyperlinkPath(visible: string, rawPath: string): string {
834+
if (process.env.NO_HYPERLINK === "1") return visible;
835+
const absolute = isAbsolute(rawPath) ? rawPath : resolveAbsolute(process.cwd(), rawPath);
836+
const url = `file://${absolute.split(pathSep).map(encodeURIComponent).join("/")}`;
837+
return `\x1b]8;;${url}\x1b\\${visible}\x1b]8;;\x1b\\`;
838+
}
839+
818840
/**
819841
* Past-tense action label, used when a tool has finished. Same shape
820842
* as `toolActionLabel` but with the verbs swapped to past tense:

src/ui/MessageList.tsx

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { AgentMessage } from "@earendil-works/pi-agent-core";
22
import { Box, Static, useStdout } from "ink";
3-
import { useEffect, useState } from "react";
3+
import { useEffect, useMemo, useState } from "react";
44
import type { ToolExecution } from "../types.js";
55
import { Message } from "./Message.js";
66

@@ -26,6 +26,27 @@ interface MessageListProps {
2626
*/
2727
export function MessageList({ messages, streaming, tools }: MessageListProps) {
2828
const width = useTerminalWidth();
29+
const rows = useTerminalRows();
30+
// Cap how many lines the streaming pane renders. The big jank class
31+
// users hit is: streaming message grows past the visible viewport,
32+
// every token update writes more lines than before, and the terminal
33+
// scrolls them down — yanking the user's manual scroll position to
34+
// the bottom on every keystroke from the model. By trimming the
35+
// streamed body to a bounded *tail* (the most recent N text lines),
36+
// the rendered region is constant-height after it fills, log-update
37+
// clears and rewrites the same number of rows every time, and the
38+
// terminal stops fighting the scrollbar. The full message goes into
39+
// <Static> once finalized so terminal scrollback still has the rest.
40+
//
41+
// Reserve a chunk for chrome (input bar, status, welcome banner).
42+
// 12 rows is conservative — leaves enough room that the user can
43+
// still see "the agent is doing something" without the tree ever
44+
// trying to exceed the viewport.
45+
const streamingTailRows = Math.max(8, rows - 12);
46+
const visibleStreaming = useMemo(
47+
() => (streaming ? clipToTail(streaming, streamingTailRows) : undefined),
48+
[streaming, streamingTailRows],
49+
);
2950
return (
3051
<Box flexDirection="column">
3152
<Static items={messages.map((message, index) => ({ message, key: indexKey(message, index) }))}>
@@ -35,15 +56,69 @@ export function MessageList({ messages, streaming, tools }: MessageListProps) {
3556
</Box>
3657
)}
3758
</Static>
38-
{streaming ? (
59+
{visibleStreaming ? (
3960
<Box marginBottom={1}>
40-
<Message message={streaming} streaming width={width} tools={tools} />
61+
<Message message={visibleStreaming} streaming width={width} tools={tools} />
4162
</Box>
4263
) : null}
4364
</Box>
4465
);
4566
}
4667

68+
/**
69+
* Trim an in-flight assistant message down to ~`maxLines` of trailing
70+
* content for live rendering. Content blocks are processed back-to-front
71+
* until we've accumulated enough lines; earlier blocks are dropped or
72+
* truncated. The original message object is unchanged — this returns a
73+
* shallow copy with a new `content` array.
74+
*
75+
* Tool calls/results stay intact (each counts as ~1 row, replacing a
76+
* dropped tool call would change the message's meaning to the user).
77+
* Text blocks at the head of the kept region get a leading-line trim
78+
* with a one-line "(earlier output trimmed)" marker so it's obvious
79+
* we're showing a tail.
80+
*/
81+
function clipToTail<T extends AgentMessage>(message: T, maxLines: number): T {
82+
if (message.role !== "assistant" || !Array.isArray(message.content)) return message;
83+
const blocks = message.content;
84+
const kept: typeof blocks = [];
85+
let remaining = maxLines;
86+
for (let i = blocks.length - 1; i >= 0; i--) {
87+
const block = blocks[i];
88+
if (remaining <= 0) break;
89+
if (block.type === "text" && typeof block.text === "string") {
90+
const lines = block.text.split("\n");
91+
if (lines.length <= remaining) {
92+
kept.unshift(block);
93+
remaining -= lines.length;
94+
} else {
95+
const tail = lines.slice(lines.length - remaining).join("\n");
96+
kept.unshift({
97+
...block,
98+
text: `(earlier output trimmed — full message in scrollback once finished)\n${tail}`,
99+
});
100+
remaining = 0;
101+
}
102+
} else if (block.type === "thinking" && typeof block.thinking === "string") {
103+
const lines = block.thinking.split("\n");
104+
if (lines.length <= remaining) {
105+
kept.unshift(block);
106+
remaining -= lines.length;
107+
} else {
108+
const tail = lines.slice(lines.length - remaining).join("\n");
109+
kept.unshift({ ...block, thinking: tail });
110+
remaining = 0;
111+
}
112+
} else {
113+
// Tool calls / results: keep verbatim, count as 1 row each.
114+
kept.unshift(block);
115+
remaining -= 1;
116+
}
117+
}
118+
if (kept.length === blocks.length) return message;
119+
return { ...message, content: kept };
120+
}
121+
47122
function useTerminalWidth(fallback = 80): number {
48123
const { stdout } = useStdout();
49124
const [width, setWidth] = useState(stdout?.columns ?? fallback);
@@ -58,6 +133,20 @@ function useTerminalWidth(fallback = 80): number {
58133
return width;
59134
}
60135

136+
function useTerminalRows(fallback = 24): number {
137+
const { stdout } = useStdout();
138+
const [rows, setRows] = useState(stdout?.rows ?? fallback);
139+
useEffect(() => {
140+
if (!stdout) return;
141+
const onResize = () => setRows(stdout.rows ?? fallback);
142+
stdout.on("resize", onResize);
143+
return () => {
144+
stdout.off("resize", onResize);
145+
};
146+
}, [stdout, fallback]);
147+
return rows;
148+
}
149+
61150
function indexKey(message: AgentMessage, index: number): string {
62151
const ts = "timestamp" in message && typeof message.timestamp === "number" ? message.timestamp : 0;
63152
return `${index}:${message.role}:${ts}`;

0 commit comments

Comments
 (0)