From 9df523094570849642ecf5846c732a4c5b126553 Mon Sep 17 00:00:00 2001 From: devswha Date: Thu, 29 Oct 2026 13:42:52 +0900 Subject: [PATCH 1/3] fix(chat): show a running Letta Code turn from the live terminal under its transcript Letta Code appends a turn to its transcript only when the turn ends (end_turn, TUI and headless), so the chat sat on the previous turn for the whole run and then jumped. Measured with real letta 0.34.23 in an isolated herdr: a 6.6 s turn wrote the file 0 times while working and once at the end; Ovis showed it 71 ms after that write. Parsing was not the cause: every raw entry is shown. While a Letta pane works or waits on the user, the transcript's turns stay and a quiet "Live terminal" box under them shows the pane's own terminal tail, read every second, 12 rows high (8 on a phone) and following its newest line inside. It goes in the render that adds the written turn (a Letta transcript changes only at a turn's end), not a frame later when herdr's status follows, so the chat does not jump. Refs #1730 --- DESIGN.md | 8 +++ changelog.d/letta-native-chat.md | 3 +- docs/guide.md | 2 +- src/components/ChatLiveTurn.test.ts | 95 +++++++++++++++++++++++++++++ src/components/ChatView.css | 19 ++++++ src/components/ChatView.tsx | 88 ++++++++++++++++++++++---- src/lib/i18n/chat.ja.ts | 1 + src/lib/i18n/chat.ko.ts | 1 + src/lib/i18n/chat.zh.ts | 1 + 9 files changed, 205 insertions(+), 13 deletions(-) create mode 100644 src/components/ChatLiveTurn.test.ts diff --git a/DESIGN.md b/DESIGN.md index 98e24b7a36..53048ccf92 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2103,6 +2103,14 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`. the ordinary cadence without a lens switch; rejected history stays hidden meanwhile. This follows T3 Code's separation of [transport health and data freshness](https://github.com/pingdotgg/t3code/blob/main/docs/internals/connection-runtime.md): an open socket alone does not prove the conversation is current. No code or new controls are borrowed. + A store that writes a turn only when it ends (Letta Code's transcript) keeps its turns on screen + while the agent works or waits on the user, and under them a quiet `Live terminal` box + (`.chat-terminal-fallback.is-live`): the pane's own terminal tail, open, read every second, + 12 rows high (8 on a phone) and following its newest line inside, so the chat around it never + moves. It goes in the render that adds the written turn, not when the pane's status follows. + The Codex and Claude apps stream the running turn into the conversation; nothing written exists + to stream here, so this follows Zed's agent panel, which shows a running command's terminal + inline in the thread rather than guessing at its words. ### Work block (`.work-block`, `.work-row`) - One per run of work in an assistant turn, in the agent's own order as its terminal shows it: diff --git a/changelog.d/letta-native-chat.md b/changelog.d/letta-native-chat.md index 5627df7886..68fc2d1a43 100644 --- a/changelog.d/letta-native-chat.md +++ b/changelog.d/letta-native-chat.md @@ -1,4 +1,5 @@ ### Added - Chat shows a Letta Code pane's conversation, read from Letta's own transcript, when herdr's Letta integration reports it and Letta's own record names the same conversation; otherwise the - terminal's text stays. + terminal's text stays. Letta writes a turn only when it ends, so while it works or waits for you, + Chat shows the live terminal. diff --git a/docs/guide.md b/docs/guide.md index 55fdc82d2e..3aded061e8 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -169,7 +169,7 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c | **Hermes** | Native SQLite session store | — use Terminal | | **Muse Code** | **Linux untested with real Muse.** Native session log, bound to the pane by the session lock Muse writes for its process; panes in one folder each show their own session. macOS and Linux; Windows uses Terminal. See [Muse chat](muse-chat.md). | — use Terminal | | **Antigravity (agy)** | **Experimental; macOS untested.** Native ledger, selected by the running process’s held conversation lock. Linux native-tested; Windows uses terminal text. See [Agy behavior and limits](agy-chat.md). | — use Terminal | -| **Letta Code** | **Experimental; Linux and macOS.** Read-only native transcript from where the pane's own `letta` process keeps it (`~/.letta/transcripts`, or its `LETTA_CONFIG_DIR` / `LETTA_TRANSCRIPT_ROOT`), when herdr's Letta integration is installed (`herdr integration install letta`, experimental), it names a conversation for the pane, and Letta's own record in the project folder (`.letta/settings.local.json`) names the same one, is older than that `letta` process and is writable by it, and `letta` is still in the folder it started in (for a local-backend conversation, Letta's local store must also name that agent for it). That holds when `letta` resumes the conversation it last had in that folder (`letta`, or `letta --conversation ` on that one). After `/new` or `/resume`, a start on another conversation (`--new` included), once `letta` has moved to another folder (its SetWorkingDirectory or EnterWorktree tools), another `letta` writing that record, while another pane runs `letta` in the same folder, with two panes on one conversation, in the home folder, or before the conversation's first turn on this PC ends, Chat uses the terminal's text until `letta` starts again on that conversation. Letta writes a turn when it ends: a turn appears once it is done, and a cancelled one never does. Only turns Letta Code wrote on this PC show. Choose **Letta Code** in **New workspace**, **New tab with agent…** or **New worktree** where `letta` is installed, or run `letta` in a herdr pane's **Terminal**. Windows uses Terminal. | — use Terminal | +| **Letta Code** | **Experimental; Linux and macOS.** Read-only native transcript from where the pane's own `letta` process keeps it (`~/.letta/transcripts`, or its `LETTA_CONFIG_DIR` / `LETTA_TRANSCRIPT_ROOT`), when herdr's Letta integration is installed (`herdr integration install letta`, experimental), it names a conversation for the pane, and Letta's own record in the project folder (`.letta/settings.local.json`) names the same one, is older than that `letta` process and is writable by it, and `letta` is still in the folder it started in (for a local-backend conversation, Letta's local store must also name that agent for it). That holds when `letta` resumes the conversation it last had in that folder (`letta`, or `letta --conversation ` on that one). After `/new` or `/resume`, a start on another conversation (`--new` included), once `letta` has moved to another folder (its SetWorkingDirectory or EnterWorktree tools), another `letta` writing that record, while another pane runs `letta` in the same folder, with two panes on one conversation, in the home folder, or before the conversation's first turn on this PC ends, Chat uses the terminal's text until `letta` starts again on that conversation. Letta writes a turn only when it ends, so while `letta` works on a turn or waits for you, Chat shows the live terminal, and the turn joins the conversation once it is done; a cancelled one never does. Only turns Letta Code wrote on this PC show. Choose **Letta Code** in **New workspace**, **New tab with agent…** or **New worktree** where `letta` is installed, or run `letta` in a herdr pane's **Terminal**. Windows uses Terminal. | — use Terminal | | **GitHub Copilot CLI** | Linux native transcript when the foreground session is verified. See [Copilot setup and limits](copilot-chat.md). | — recorded choices are read-only; answer questions and approvals in Terminal | | **Anything else** | The terminal's text | — use Terminal | diff --git a/src/components/ChatLiveTurn.test.ts b/src/components/ChatLiveTurn.test.ts new file mode 100644 index 0000000000..7221f8aa55 --- /dev/null +++ b/src/components/ChatLiveTurn.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { SettingsProvider } from "../lib/settings.ts"; +import { liveTurnShown, nextLiveTurnArm, TerminalFallback, turnOnlyInTerminal, type LiveTurnArm } from "./ChatView.tsx"; + +const render = (live: boolean): string => { + const languages = Object.getOwnPropertyDescriptor(navigator, "languages"); + Object.defineProperty(navigator, "languages", { configurable: true, value: ["en"] }); + try { + return renderToStaticMarkup(createElement(SettingsProvider, { children: createElement(TerminalFallback, { paneId: "w1:p1", visible: true, live }) })); + } finally { + if (languages) Object.defineProperty(navigator, "languages", languages); + else Reflect.deleteProperty(navigator, "languages"); + } +}; + +/** Renders of one pane: each step is what ChatView holds then, and whether the box shows. */ +function run(steps: { running: boolean; started: number; settled: number | null; written: string }[]): boolean[] { + let arm: LiveTurnArm | null = null; + return steps.map(({ running, started, settled, written }) => { + arm = nextLiveTurnArm(arm, "w1:p1", running, started, settled === null ? null : { pane: "w1:p1", read: settled }, written); + return running && liveTurnShown(arm, written); + }); +} + +describe("a Letta turn still running", () => { + it("is in the terminal only while Letta works or waits on the user: its transcript gets the turn when it ends", () => { + expect(turnOnlyInTerminal("letta-transcript", "working")).toBe(true); + expect(turnOnlyInTerminal("letta-transcript", "blocked")).toBe(true); + expect(turnOnlyInTerminal("letta-transcript", "idle")).toBe(false); + expect(turnOnlyInTerminal("letta-transcript", "done")).toBe(false); + expect(turnOnlyInTerminal("letta-transcript", undefined)).toBe(false); + expect(turnOnlyInTerminal("omo-transcript", "working")).toBe(false); + expect(turnOnlyInTerminal("claude-transcript", "working")).toBe(false); + expect(turnOnlyInTerminal(undefined, "working")).toBe(false); + }); + + it("goes in the render that adds the written turn, before the pane's status follows", () => { + expect(run([ + { running: true, started: 3, settled: 3, written: "4" }, + { running: true, started: 4, settled: 4, written: "4" }, + { running: true, started: 5, settled: 5, written: "6" }, + { running: false, started: 5, settled: 5, written: "6" }, + ])).toEqual([true, true, false, false]); + }); + + it("stays for the running turn when the transcript shown first was older: a cached answer, or a page that was hidden", () => { + expect(run([ + { running: true, started: 0, settled: null, written: "2" }, + { running: true, started: 1, settled: 1, written: "4" }, + { running: true, started: 2, settled: 2, written: "4" }, + { running: true, started: 3, settled: 3, written: "6" }, + ])).toEqual([true, true, true, false]); + }); + + it("takes no baseline from a read that started before the turn ran", () => { + expect(run([ + { running: true, started: 7, settled: 6, written: "4" }, + { running: true, started: 8, settled: 7, written: "6" }, + { running: true, started: 8, settled: 8, written: "6" }, + { running: true, started: 9, settled: 9, written: "8" }, + ])).toEqual([true, true, true, false]); + }); + + it("arms afresh after the chat was hidden or offline, when a turn may have ended and the next begun unseen", () => { + expect(run([ + { running: true, started: 2, settled: 2, written: "4" }, + { running: true, started: 3, settled: 3, written: "4" }, + { running: false, started: 3, settled: 3, written: "4" }, + { running: true, started: 3, settled: 3, written: "4" }, + { running: true, started: 4, settled: 4, written: "6" }, + { running: true, started: 5, settled: 5, written: "8" }, + ])).toEqual([true, true, false, true, true, false]); + }); + + it("arms afresh for the next turn and for another pane", () => { + let arm = nextLiveTurnArm(null, "w1:p1", true, 1, { pane: "w1:p1", read: 2 }, "4"); + arm = nextLiveTurnArm(arm, "w1:p1", true, 2, { pane: "w1:p1", read: 2 }, "4"); + expect(arm?.key).toBe("4"); + expect(nextLiveTurnArm(arm, "w1:p2", true, 2, { pane: "w1:p1", read: 2 }, "9")).toEqual({ pane: "w1:p2", after: 2, key: null }); + expect(nextLiveTurnArm(arm, "w1:p1", false, 2, { pane: "w1:p1", read: 2 }, "6")).toBeNull(); + }); + + it("opens under the turns with its full height from the start, out of the chat's live announcements", () => { + const live = render(true); + expect(live).toContain('class="chat-terminal-fallback is-live" open=""'); + expect(live).toMatch(/]*aria-live="off"[^>]*><\/pre>/); + const fallback = render(false); + expect(fallback).toContain('class="chat-terminal-fallback"'); + expect(fallback).not.toContain("open="); + expect(fallback).not.toContain("; } -/** Optional terminal output must never hold initial display or transcript polling. */ -function TerminalFallback({ paneId, visible }: { paneId: string; visible: boolean }) { +/** + * Stores that write a turn only when it ends: Letta Code appends the whole turn to its transcript + * at `end_turn` (server/letta.ts), so while the agent works or waits on the user, only its terminal + * shows the turn. + */ +const TURN_END_SOURCES: ReadonlySet = new Set(["letta-transcript"]); +/** The pane's newest lines stand for the running turn: about a screen of the agent's own TUI. */ +const LIVE_TURN_LINES = 60; +/** The running turn's terminal is read every second, as fast as pushed transcript reads go. */ +const LIVE_TURN_POLL_MS = 1000; + +/** Whether the turn the agent is on is not in its transcript yet, so the terminal shows it under the chat. */ +export function turnOnlyInTerminal(source: ConversationResponse["source"] | undefined, status: AgentStatus | undefined): boolean { + return source !== undefined && TURN_END_SOURCES.has(source) && (status === "working" || status === "blocked"); +} + +/** + * The live box of one running turn: `after` counts the pane's newest-page reads started before the + * turn ran; `key` is the transcript (its newest turn as a key) the first read started after it + * returned. That read cannot hold the running turn: such a store appends a turn only when it ends. + */ +export type LiveTurnArm = { pane: string; after: number; key: string | null }; + +/** + * The arm for this render. A transcript shown before the turn ran (a cached answer, a page that + * was hidden) is no baseline: turns written meanwhile would read as this one's end. + */ +export function nextLiveTurnArm(arm: LiveTurnArm | null, pane: string, running: boolean, readsStarted: number, settled: { pane: string; read: number } | null, written: string): LiveTurnArm | null { + if (!running) return null; + if (arm === null || arm.pane !== pane) return { pane, after: readsStarted, key: null }; + if (arm.key === null && settled !== null && settled.pane === pane && settled.read > arm.after) return { ...arm, key: written }; + return arm; +} + +/** The box shows until the transcript moves past its baseline: then it holds the turn, and the box goes in that render. */ +export function liveTurnShown(arm: LiveTurnArm | null, written: string): boolean { + return arm !== null && (arm.key === null || arm.key === written); +} + +/** + * Optional terminal output must never hold initial display or transcript polling. `live`: the turn + * a TURN_END_SOURCES agent is on, open from the start under the turns its transcript holds. + */ +export function TerminalFallback({ paneId, visible, live = false }: { paneId: string; visible: boolean; live?: boolean }) { const { fetchPaneTranscript } = useMachineApi(); const t = useT(); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(live); + const tail = useRef(null); + /** the reader has not scrolled up inside the box: it follows the terminal's newest line */ + const following = useRef(true); const [text, setText] = useState(null); const [error, setError] = useState(null); const [retryKey, setRetryKey] = useState(0); @@ -1198,23 +1243,29 @@ function TerminalFallback({ paneId, visible }: { paneId: string; visible: boolea let timer: number | undefined; const read = async (): Promise => { try { - const result = await fetchPaneTranscript(paneId, TRANSCRIPT_LINES); + const result = await fetchPaneTranscript(paneId, live ? LIVE_TURN_LINES : TRANSCRIPT_LINES); if (cancelled) return; setText(toTranscriptMessages(result.text).filter((message) => message.role !== "status").map((message) => message.text).join("\n\n")); setError(null); } catch (cause) { if (!cancelled) setError(userFailure(cause)); } finally { - if (!cancelled) timer = window.setTimeout(() => void read(), PROMPT_POLL_MS); + if (!cancelled) timer = window.setTimeout(() => void read(), live ? LIVE_TURN_POLL_MS : PROMPT_POLL_MS); } }; void read(); return () => { cancelled = true; window.clearTimeout(timer); }; - }, [paneId, open, visible, fetchPaneTranscript, retryKey]); - return
setOpen(event.currentTarget.open)}> - {t("Conversation unavailable — show terminal output")} - {text !== null &&
{text}
} - {text === null && error === null &&

{t("Loading…")}

} + }, [paneId, open, visible, live, fetchPaneTranscript, retryKey]); + // a live turn's box is a few rows high and follows the terminal's newest line + useLayoutEffect(() => { if (live && following.current && tail.current) tail.current.scrollTop = tail.current.scrollHeight; }, [live, text]); + return
setOpen(event.currentTarget.open)}> + {live ? t("Live terminal") : t("Conversation unavailable — show terminal output")} + {live + // its full height from the start, so the chat never moves as text arrives; terminal noise is not announced + ?
 { const box = event.currentTarget; following.current = box.scrollTop + box.clientHeight >= box.scrollHeight - 4; }}>{text ?? ""}
+ : text !== null &&
{text}
} + {!live && text === null && error === null &&

{t("Loading…")}

} {error !== null && setRetryKey((key) => key + 1)} />}
; } @@ -1244,6 +1295,17 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); const smoothFollow = followGlides({ working: activity !== "idle" && agentStatus !== "blocked", reducedMotion }); const workStatus = state.activity !== undefined && agentStatus !== "blocked" ? activity === "idle" ? "idle" : "working" : agentStatus; + const newestTurn = state.turns.at(-1); + const writtenKey = `${state.turns.length}\0${newestTurn?.ts ?? ""}\0${newestTurn?.end_ts ?? ""}`; + const turnRunning = state.source === "conversation" && turnOnlyInTerminal(state.conversationSource, agentStatus); + /** newest-page reads started, and the last one laid out (or confirmed unchanged): the live box's baseline */ + const newestReads = useRef(0); + const [settledRead, setSettledRead] = useState<{ pane: string; read: number } | null>(null); + const [liveArm, setLiveArm] = useState(null); + // a chat hidden or offline can miss a turn's end and the next one's start: it arms afresh when back + const nextLiveArm = nextLiveTurnArm(liveArm, paneId, turnRunning && connected && visible, newestReads.current, settledRead, writtenKey); + if (nextLiveArm !== liveArm) setLiveArm(nextLiveArm); + const liveTurnOpen = turnRunning && liveTurnShown(nextLiveArm, writtenKey); const [error, setError] = useState(null); const [errorStatus, setErrorStatus] = useState(null); /** reads failed in a row: a slow PC is waited for this many times before the chat alarms */ @@ -1470,6 +1532,7 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish return before === held ? pages.flat() : null; }; const read = async (): Promise => { + const started = ++newestReads.current; const readKey = presentationRef.current; const finish = finishRef.current; const previousAnswer = lastAnswer.current ?? undefined; @@ -1518,7 +1581,7 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish if (!continued) { pagingAway.current = false; stickToBottom.current = true; lastRowOffset.current = null; lastScrollHeight.current = 0; setNewMessages(false); setAway(false); } } // a 304 hands back the answer already shown: nothing to compare or lay out again - if (conversation === lastAnswer.current) { setError(null); setErrorStatus(null); setFailures(0); setReadPresentation(proved ? readKey : undefined); return; } + if (conversation === lastAnswer.current) { setError(null); setErrorStatus(null); setFailures(0); setReadPresentation(proved ? readKey : undefined); setSettledRead({ pane: paneId, read: started }); return; } // The newest page moved past the held start: the turns in between join the older // pages and the newest page is held from its new start, so no poll reads more than a page. const previous = lastAnswer.current; @@ -1555,6 +1618,7 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish } showAnswer(conversation, next); setReadPresentation(proved ? readKey : undefined); + setSettledRead({ pane: paneId, read: started }); } catch (cause) { if (cancelled || generation !== olderGeneration.current) return; setLoaded(true); @@ -2242,6 +2306,8 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish : )} {loaded && error === null && agent === "grok" && state.source !== "conversation" &&

{t("Grok chat is unavailable. Open Terminal to see activity and tool calls.")}

} {loaded && error === null && agent === "agy" && state.source !== "conversation" &&

{t("Antigravity chat is unavailable. Open Terminal to see activity and tool calls.")}

} + {loaded && error === null && connected && !ended && liveTurnOpen + && } {/* The agent's activity, where no open work block says it: before the turn has an assistant or tool row (Claude Code's spinner writes nothing to the transcript), and while a native turn thinks (a work block's head says Working). Without native activity the pane status stands diff --git a/src/lib/i18n/chat.ja.ts b/src/lib/i18n/chat.ja.ts index fafdef0752..fa58b64228 100644 --- a/src/lib/i18n/chat.ja.ts +++ b/src/lib/i18n/chat.ja.ts @@ -43,6 +43,7 @@ export const CHAT_JA: Record = { "Earlier messages": "以前のメッセージ", "beginning of conversation": "会話の始まり", "Conversation unavailable — show terminal output": "会話を読み取れません。ターミナルの出力を表示", + "Live terminal": "ライブターミナル", "No conversation yet — say something below": "まだ会話はありません。下から話しかけてください", "New messages": "新しいメッセージ", "{n} edit": "編集 {n} 件", diff --git a/src/lib/i18n/chat.ko.ts b/src/lib/i18n/chat.ko.ts index 1a8cc1d8d4..d49638ad35 100644 --- a/src/lib/i18n/chat.ko.ts +++ b/src/lib/i18n/chat.ko.ts @@ -43,6 +43,7 @@ export const CHAT_KO: Record = { "Earlier messages": "이전 메시지", "beginning of conversation": "대화의 시작", "Conversation unavailable — show terminal output": "대화를 읽을 수 없습니다. 터미널 출력 보기", + "Live terminal": "실시간 터미널", "No conversation yet — say something below": "아직 대화가 없습니다. 아래에 말을 걸어 보세요", "New messages": "새 메시지", "{n} edit": "편집 {n}개", diff --git a/src/lib/i18n/chat.zh.ts b/src/lib/i18n/chat.zh.ts index 55e3da4294..b1cb389db6 100644 --- a/src/lib/i18n/chat.zh.ts +++ b/src/lib/i18n/chat.zh.ts @@ -43,6 +43,7 @@ export const CHAT_ZH: Record = { "Earlier messages": "更早的消息", "beginning of conversation": "对话开始", "Conversation unavailable — show terminal output": "对话不可用。显示终端输出", + "Live terminal": "实时终端", "No conversation yet — say something below": "暂无对话。在下方说点什么吧", "New messages": "新消息", "{n} edit": "{n} 次编辑", From fe208decc595bb36e5d82b9d02f4a14529966c2c Mon Sep 17 00:00:00 2001 From: devswha Date: Thu, 29 Oct 2026 14:20:13 +0900 Subject: [PATCH 2/3] fix(chat): cut Letta's input box and status row from the live terminal box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner asked for the box to show the work, not Letta's own chrome. Cut by the layout Letta Code 0.34.23 draws at the bottom of its screen: the input box between two rules (its first row the `›` prompt, with whatever the user is typing), the status row under it, and the tip rows hanging under the spinner row. A screen in any other shape is shown as it is. Tested on screens recorded from a real run. Refs #1730 --- DESIGN.md | 3 ++ src/components/ChatView.tsx | 4 +- src/lib/fixtures-letta-screens/README.md | 13 +++++ .../fixtures-letta-screens/done-0.34.23.txt | 30 ++++++++++++ .../working-tool-0.34.23.txt | 30 ++++++++++++ .../working-typed-0.34.23.txt | 30 ++++++++++++ src/lib/lettaScreen.test.ts | 49 +++++++++++++++++++ src/lib/lettaScreen.ts | 34 +++++++++++++ 8 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 src/lib/fixtures-letta-screens/README.md create mode 100644 src/lib/fixtures-letta-screens/done-0.34.23.txt create mode 100644 src/lib/fixtures-letta-screens/working-tool-0.34.23.txt create mode 100644 src/lib/fixtures-letta-screens/working-typed-0.34.23.txt create mode 100644 src/lib/lettaScreen.test.ts create mode 100644 src/lib/lettaScreen.ts diff --git a/DESIGN.md b/DESIGN.md index 53048ccf92..f04a429dfc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2108,6 +2108,9 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`. (`.chat-terminal-fallback.is-live`): the pane's own terminal tail, open, read every second, 12 rows high (8 on a phone) and following its newest line inside, so the chat around it never moves. It goes in the render that adds the written turn, not when the pane's status follows. + Letta's own input box, the status row under it and the tip under its spinner are cut from the + tail by the layout Letta draws (`src/lib/lettaScreen.ts`); a screen in another shape is shown + as it is. The Codex and Claude apps stream the running turn into the conversation; nothing written exists to stream here, so this follows Zed's agent panel, which shows a running command's terminal inline in the thread rather than guessing at its words. diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 01b8da6c12..5c3173f135 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -45,6 +45,7 @@ import { askedQuestions } from "../../shared/askQuestions.ts"; import { ConversationRefresh } from "../lib/conversationRefresh.ts"; import { teammateMessageParts, type TeammateMessage } from "../lib/teammateMessage.ts"; import { usePageVisible } from "../lib/visibility.ts"; +import { lettaScreenWork } from "../lib/lettaScreen.ts"; import { dismissKeyboardOn } from "../lib/keyboard.ts"; import { useFacesArrived } from "../lib/fontFaces.ts"; import { OpenFileContext } from "../lib/filePaths.ts"; @@ -1245,7 +1246,8 @@ export function TerminalFallback({ paneId, visible, live = false }: { paneId: st try { const result = await fetchPaneTranscript(paneId, live ? LIVE_TURN_LINES : TRANSCRIPT_LINES); if (cancelled) return; - setText(toTranscriptMessages(result.text).filter((message) => message.role !== "status").map((message) => message.text).join("\n\n")); + // a live box is Letta's (TURN_END_SOURCES): its input box and status line are not the work + setText(toTranscriptMessages(live ? lettaScreenWork(result.text) : result.text).filter((message) => message.role !== "status").map((message) => message.text).join("\n\n")); setError(null); } catch (cause) { if (!cancelled) setError(userFailure(cause)); diff --git a/src/lib/fixtures-letta-screens/README.md b/src/lib/fixtures-letta-screens/README.md new file mode 100644 index 0000000000..a4636466fc --- /dev/null +++ b/src/lib/fixtures-letta-screens/README.md @@ -0,0 +1,13 @@ +# Letta Code screens + +Recorded 2026-10-29 in an isolated herdr session (herdr 0.9.5) with Letta Code 0.34.23 (local backend, +gemini-3.1-flash-lite through a local OpenAI-compatible proxy) for the chat's live terminal box +(`src/lib/lettaScreen.ts`), read by `src/lib/lettaScreen.test.ts`. Each file is the last 30 lines of +`herdr pane read --source recent_unwrapped --format text --lines 60`, as the box reads the pane. + +- `working-tool-0.34.23.txt`: a turn at work, its Bash call running, under the spinner row and its tip. +- `working-typed-0.34.23.txt`: the same turn later, with a follow-up typed into Letta's input. +- `done-0.34.23.txt`: right after the turn ended (no spinner row). + +Redacted: the shell prompt's user and host read `user@host`, and the user and group in `ls -la` +read `user000`, the same width as before, so the layout is unchanged. diff --git a/src/lib/fixtures-letta-screens/done-0.34.23.txt b/src/lib/fixtures-letta-screens/done-0.34.23.txt new file mode 100644 index 0000000000..cdfd717f9f --- /dev/null +++ b/src/lib/fixtures-letta-screens/done-0.34.23.txt @@ -0,0 +1,30 @@ + drwxrwxr-x 4 user000 user000 4096 Oct 29 14:15 . + drwxrwxr-x 6 user000 user000 4096 Oct 29 14:15 .. + … +3 lines (ctrl+o to expand) + +• Lighthouses have stood for centuries as stoic guardians of the coastline, casting their sweeping beams across + treacherous waters to guide mariners safely home. These remarkable structures, often perched on jagged cliffs or + solitary offshore reefs, represent one of humanity's most enduring triumphs over the perilous nature of the sea. Long + before the advent of modern GPS and satellite navigation, a lighthouse was the singular difference between life and + shipwreck for sailors navigating dark nights, violent storms, and hidden shoals. + + The history of the lighthouse is deeply intertwined with human ingenuity and maritime trade. From the ancient Pharos + of Alexandria—one of the Seven Wonders of the Ancient World—to the sophisticated cast-iron and steel towers of the + nineteenth and twentieth centuries, lighthouse engineering evolved alongside human civilization. Early beacons relied + simply on wood or coal fires burned in open braziers, which gradually gave way to enclosed lanterns fueled by whale + oil, lard, and eventually kerosene. The true revolution, however, arrived with the invention of the Fresnel lens in + the early 1800s. This intricate array of glass prisms and lenses captured and magnified the light of a single lamp, + projecting a brilliant beam visible for miles across the horizon. + + Beyond their practical utility, lighthouses hold a profound place in cultural folklore and human imagination. They + symbolize safety, resilience, and unwavering steadfastness in the face of overwhelming odds. The lives of lighthouse + keepers were defined by profound isolation and relentless dedication; they braved fierce elements, tended delicate + machinery, and maintained the vital flame through blinding blizzards and howling gales. Although automation has + largely replaced the solitary keeper with remote sensors and computerized LED arrays, the romantic allure of the + lighthouse remains undiminished. Today, these historic beacons continue to captivate our spirits, reminding us of our + eternal quest to find light amidst the darkness. + +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +› a queued follow-up I typed +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Letta Code · gemini-3.1-flash-lite diff --git a/src/lib/fixtures-letta-screens/working-tool-0.34.23.txt b/src/lib/fixtures-letta-screens/working-tool-0.34.23.txt new file mode 100644 index 0000000000..53b796dac1 --- /dev/null +++ b/src/lib/fixtures-letta-screens/working-tool-0.34.23.txt @@ -0,0 +1,30 @@ + + + +• Creating a new agent + → /rename name your agent + → /init initialize your agent's memory + → /login sign in with Letta + → /agents list all agents + → /resume resume a previous conversation + + +› Reply with exactly: first turn ok + + +• first turn ok + + +› Use the shell tool to run: ls -la . Then write a 300-word essay about lighthouses. + + +• List List files in current directory + └ Running... (0s) + +⡛ Letta Code is deliberating… (esc to interrupt) + └ Tip: Use /search [query] to search messages across all agents. + +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +› +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Letta Code · gemini-3.1-flash-lite diff --git a/src/lib/fixtures-letta-screens/working-typed-0.34.23.txt b/src/lib/fixtures-letta-screens/working-typed-0.34.23.txt new file mode 100644 index 0000000000..1c2058a193 --- /dev/null +++ b/src/lib/fixtures-letta-screens/working-typed-0.34.23.txt @@ -0,0 +1,30 @@ +• Creating a new agent + → /rename name your agent + → /init initialize your agent's memory + → /login sign in with Letta + → /agents list all agents + → /resume resume a previous conversation + + +› Reply with exactly: first turn ok + + +• first turn ok + + +› Use the shell tool to run: ls -la . Then write a 300-word essay about lighthouses. + + +• List List files in current directory + └ total 20 + drwxrwxr-x 4 user000 user000 4096 Oct 29 14:15 . + drwxrwxr-x 6 user000 user000 4096 Oct 29 14:15 .. + … +3 lines (ctrl+o to expand) + +⠍ Letta Code is adapting… (esc to interrupt · 250 ↓) + └ Tip: Use /search [query] to search messages across all agents. + +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +› a queued follow-up I typed +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Letta Code · gemini-3.1-flash-lite diff --git a/src/lib/lettaScreen.test.ts b/src/lib/lettaScreen.test.ts new file mode 100644 index 0000000000..fc4ada66ef --- /dev/null +++ b/src/lib/lettaScreen.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { lettaScreenWork } from "./lettaScreen.ts"; + +const screen = (name: string): string => readFileSync(join(import.meta.dir, "fixtures-letta-screens", `${name}-0.34.23.txt`), "utf8"); +const lines = (text: string): string[] => text.replace(/\n+$/, "").split("\n"); +const RULE = /^─{20,}$/; + +describe("the work on a Letta Code screen", () => { + it("ends at the spinner row of a turn at work: its tip, input box and status line are cut", () => { + for (const name of ["working-tool", "working-typed"]) { + const recorded = lines(screen(name)); + const spinner = recorded.findIndex((line) => line.includes("(esc to interrupt")); + expect(spinner).toBeGreaterThan(0); + const work = lines(lettaScreenWork(screen(name))); + expect(work).toEqual(recorded.slice(0, spinner + 1)); + expect(work.some((line) => RULE.test(line) || line.includes("Tip:") || line.trimStart().startsWith("Letta Code ·"))).toBe(false); + } + }); + + it("keeps what the user typed into Letta's input out of the box", () => { + expect(screen("working-typed")).toContain("› a queued follow-up I typed"); + expect(lettaScreenWork(screen("working-typed"))).not.toContain("a queued follow-up I typed"); + }); + + it("keeps the rows a tool call hangs under itself", () => { + expect(lettaScreenWork(screen("working-tool"))).toContain(" └ Running... (0s)"); + }); + + it("ends at the answer once the turn is over", () => { + const recorded = lines(screen("done")); + const box = recorded.findIndex((line, index) => RULE.test(line) && recorded[index + 1]?.startsWith("›")); + expect(lines(lettaScreenWork(screen("done")))).toEqual(recorded.slice(0, box).join("\n").replace(/\n+$/, "").split("\n")); + }); + + it("shows a screen it does not recognise as Letta's unchanged", () => { + const shell = "user@host:~/proj$ ls\nnotes.txt\nuser@host:~/proj$ "; + expect(lettaScreenWork(shell)).toBe(shell); + const recorded = screen("working-tool"); + const noStatusLine = lines(recorded).slice(0, -1).join("\n"); + expect(lettaScreenWork(noStatusLine)).toBe(noStatusLine); + const tallFooter = `${recorded}one\ntwo\nthree\n`; + expect(lettaScreenWork(tallFooter)).toBe(tallFooter); + const noPrompt = recorded.replace("\n›\n", "\nsomething else\n"); + expect(lettaScreenWork(noPrompt)).toBe(noPrompt); + }); +}); diff --git a/src/lib/lettaScreen.ts b/src/lib/lettaScreen.ts new file mode 100644 index 0000000000..ddda2c8be7 --- /dev/null +++ b/src/lib/lettaScreen.ts @@ -0,0 +1,34 @@ +/** + * The work on a Letta Code screen, without the chrome Letta draws under it (letta-code 0.34.23, + * recorded in src/lib/fixtures-letta-screens): at the bottom an input box between two rules, its + * first row Letta's `›` prompt (with what the user is typing), and one status row under it + * (`Letta Code · `, two when the terminal wraps it); while a turn runs, the rows a tip hangs + * (`└`) under the spinner row (`… (esc to interrupt)`) just above the box. A screen in any other + * shape is returned as it is: nothing is cut on a guess. + */ +const RULE = /^─{20,}$/; +const PROMPT = /^›/; +const STATUS_ROWS = 2; +const HANGING = /^\s+└/; +const SPINNER = /\(esc to interrupt/; + +export function lettaScreenWork(screen: string): string { + const rows = screen.split("\n"); + const bare = rows.map((row) => row.trimEnd()); + let end = bare.length; + while (end > 0 && bare[end - 1] === "") end--; + // the rule over the status row: the status row and nothing else below it + let lower = -1; + for (let row = end - 1; row >= Math.max(0, end - 1 - STATUS_ROWS); row--) if (RULE.test(bare[row]!)) { lower = row; break; } + if (lower < 0 || lower === end - 1) return screen; + // the rule over the input box, whose first row is the prompt + let upper = -1; + for (let row = lower - 1; row >= 0; row--) if (RULE.test(bare[row]!)) { upper = row; break; } + if (upper < 0 || upper === lower - 1 || !PROMPT.test(bare[upper + 1]!)) return screen; + let work = upper; + while (work > 0 && bare[work - 1] === "") work--; + let hung = work; + while (hung > 0 && HANGING.test(bare[hung - 1]!)) hung--; + if (hung < work && hung > 0 && SPINNER.test(bare[hung - 1]!)) work = hung; + return rows.slice(0, work).join("\n"); +} From 2eb74465bcc5b0051ade60f01055395ed8756da2 Mon Sep 17 00:00:00 2001 From: devswha Date: Thu, 29 Oct 2026 14:41:38 +0900 Subject: [PATCH 3/3] fix(chat): keep the Letta live box until the read after the turn's end Letta writes a turn before herdr's status says it ended, but the chat reads it after: the box went on the status edge, so until that read landed (longer when it was slow, and for good when it failed) the chat showed neither the box nor the turn. Past the edge the box now stays until a read started after it ends: it goes in the render that lays the turn out, or when that read fails (both bounded by the chat's read deadline). A cancelled turn, never written, ends on that read too. A chat hidden or offline still drops the box and arms afresh when back. Refs #1730 --- src/components/ChatLiveTurn.test.ts | 44 +++++++++++++++++++++------ src/components/ChatView.tsx | 47 +++++++++++++++++++---------- 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/components/ChatLiveTurn.test.ts b/src/components/ChatLiveTurn.test.ts index 7221f8aa55..a3e21a7188 100644 --- a/src/components/ChatLiveTurn.test.ts +++ b/src/components/ChatLiveTurn.test.ts @@ -17,11 +17,11 @@ const render = (live: boolean): string => { }; /** Renders of one pane: each step is what ChatView holds then, and whether the box shows. */ -function run(steps: { running: boolean; started: number; settled: number | null; written: string }[]): boolean[] { +function run(steps: { watching?: boolean; running: boolean; started: number; settled: number | null; ok?: boolean; written: string }[]): boolean[] { let arm: LiveTurnArm | null = null; - return steps.map(({ running, started, settled, written }) => { - arm = nextLiveTurnArm(arm, "w1:p1", running, started, settled === null ? null : { pane: "w1:p1", read: settled }, written); - return running && liveTurnShown(arm, written); + return steps.map(({ watching = true, running, started, settled, ok = true, written }) => { + arm = nextLiveTurnArm(arm, "w1:p1", watching, running, started, settled === null ? null : { pane: "w1:p1", read: settled, ok }, written); + return liveTurnShown(arm, written); }); } @@ -64,11 +64,34 @@ describe("a Letta turn still running", () => { ])).toEqual([true, true, true, false]); }); - it("arms afresh after the chat was hidden or offline, when a turn may have ended and the next begun unseen", () => { + it("stays past the status edge until a read started after it shows the turn, so the chat is never without it", () => { expect(run([ { running: true, started: 2, settled: 2, written: "4" }, { running: true, started: 3, settled: 3, written: "4" }, { running: false, started: 3, settled: 3, written: "4" }, + { running: false, started: 4, settled: 3, written: "4" }, + { running: false, started: 4, settled: 4, written: "6" }, + ])).toEqual([true, true, true, true, false]); + }); + + it("goes after the status edge once a read started after it fails, or shows no turn: a cancelled turn is never written", () => { + expect(run([ + { running: true, started: 2, settled: 2, written: "4" }, + { running: false, started: 2, settled: 2, written: "4" }, + { running: false, started: 3, settled: 3, ok: false, written: "4" }, + ])).toEqual([true, true, false]); + expect(run([ + { running: true, started: 2, settled: 2, written: "4" }, + { running: false, started: 2, settled: 2, written: "4" }, + { running: false, started: 3, settled: 3, written: "4" }, + ])).toEqual([true, true, false]); + }); + + it("arms afresh after the chat was hidden or offline, when a turn may have ended and the next begun unseen", () => { + expect(run([ + { running: true, started: 2, settled: 2, written: "4" }, + { running: true, started: 3, settled: 3, written: "4" }, + { watching: false, running: true, started: 3, settled: 3, written: "4" }, { running: true, started: 3, settled: 3, written: "4" }, { running: true, started: 4, settled: 4, written: "6" }, { running: true, started: 5, settled: 5, written: "8" }, @@ -76,11 +99,14 @@ describe("a Letta turn still running", () => { }); it("arms afresh for the next turn and for another pane", () => { - let arm = nextLiveTurnArm(null, "w1:p1", true, 1, { pane: "w1:p1", read: 2 }, "4"); - arm = nextLiveTurnArm(arm, "w1:p1", true, 2, { pane: "w1:p1", read: 2 }, "4"); + const read = { pane: "w1:p1", read: 2, ok: true }; + let arm = nextLiveTurnArm(null, "w1:p1", true, true, 1, read, "4"); + arm = nextLiveTurnArm(arm, "w1:p1", true, true, 2, read, "4"); expect(arm?.key).toBe("4"); - expect(nextLiveTurnArm(arm, "w1:p2", true, 2, { pane: "w1:p1", read: 2 }, "9")).toEqual({ pane: "w1:p2", after: 2, key: null }); - expect(nextLiveTurnArm(arm, "w1:p1", false, 2, { pane: "w1:p1", read: 2 }, "6")).toBeNull(); + expect(nextLiveTurnArm(arm, "w1:p2", true, true, 2, read, "9")).toEqual({ pane: "w1:p2", after: 2, key: null, ended: null }); + const ended = nextLiveTurnArm(arm, "w1:p1", true, false, 2, read, "4"); + expect(nextLiveTurnArm(ended, "w1:p1", true, true, 3, read, "4")).toEqual({ pane: "w1:p1", after: 3, key: null, ended: null }); + expect(nextLiveTurnArm(arm, "w1:p1", false, true, 2, read, "6")).toBeNull(); }); it("opens under the turns with its full height from the start, out of the chat's live announcements", () => { diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 5c3173f135..6a60d5d64f 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1205,18 +1205,33 @@ export function turnOnlyInTerminal(source: ConversationResponse["source"] | unde * The live box of one running turn: `after` counts the pane's newest-page reads started before the * turn ran; `key` is the transcript (its newest turn as a key) the first read started after it * returned. That read cannot hold the running turn: such a store appends a turn only when it ends. + * `ended`: the reads started when the pane's status said the turn was over. */ -export type LiveTurnArm = { pane: string; after: number; key: string | null }; +export type LiveTurnArm = { pane: string; after: number; key: string | null; ended: number | null }; +/** The pane's last newest-page read to end: laid out (or confirmed unchanged by a 304), or failed. */ +export type SettledRead = { pane: string; read: number; ok: boolean }; /** - * The arm for this render. A transcript shown before the turn ran (a cached answer, a page that - * was hidden) is no baseline: turns written meanwhile would read as this one's end. + * The arm for this render. `watching`: the chat is up and online on such a transcript; a chat + * hidden or offline can miss a turn's end and the next one's start, so it arms afresh when back. A + * transcript shown before the turn ran (a cached answer, a page that was hidden) is no baseline: + * turns written meanwhile would read as this one's end. Letta writes the turn before herdr's status + * says it ended, but the chat reads it after: past that edge the box stays until a read started + * after it ends, and it goes in the render that lays the turn out, or when that read fails (each is + * bounded by the chat's read deadline). A turn cancelled is never written: that read ends it too. */ -export function nextLiveTurnArm(arm: LiveTurnArm | null, pane: string, running: boolean, readsStarted: number, settled: { pane: string; read: number } | null, written: string): LiveTurnArm | null { - if (!running) return null; - if (arm === null || arm.pane !== pane) return { pane, after: readsStarted, key: null }; - if (arm.key === null && settled !== null && settled.pane === pane && settled.read > arm.after) return { ...arm, key: written }; - return arm; +export function nextLiveTurnArm(arm: LiveTurnArm | null, pane: string, watching: boolean, running: boolean, readsStarted: number, settled: SettledRead | null, written: string): LiveTurnArm | null { + if (!watching) return null; + const held = arm !== null && arm.pane === pane ? arm : null; + const read = settled !== null && settled.pane === pane ? settled : null; + if (running) { + if (held === null || held.ended !== null) return { pane, after: readsStarted, key: null, ended: null }; + if (held.key === null && read !== null && read.ok && read.read > held.after) return { ...held, key: written }; + return held; + } + if (held === null) return null; + if (held.ended === null) return { ...held, ended: readsStarted }; + return read !== null && read.read > held.ended ? null : held; } /** The box shows until the transcript moves past its baseline: then it holds the turn, and the box goes in that render. */ @@ -1299,15 +1314,14 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish const workStatus = state.activity !== undefined && agentStatus !== "blocked" ? activity === "idle" ? "idle" : "working" : agentStatus; const newestTurn = state.turns.at(-1); const writtenKey = `${state.turns.length}\0${newestTurn?.ts ?? ""}\0${newestTurn?.end_ts ?? ""}`; - const turnRunning = state.source === "conversation" && turnOnlyInTerminal(state.conversationSource, agentStatus); - /** newest-page reads started, and the last one laid out (or confirmed unchanged): the live box's baseline */ + const turnEndView = state.source === "conversation" && state.conversationSource !== undefined && TURN_END_SOURCES.has(state.conversationSource); + /** newest-page reads started, and the last one to end: the live box's baseline and its end */ const newestReads = useRef(0); - const [settledRead, setSettledRead] = useState<{ pane: string; read: number } | null>(null); + const [settledRead, setSettledRead] = useState(null); const [liveArm, setLiveArm] = useState(null); - // a chat hidden or offline can miss a turn's end and the next one's start: it arms afresh when back - const nextLiveArm = nextLiveTurnArm(liveArm, paneId, turnRunning && connected && visible, newestReads.current, settledRead, writtenKey); + const nextLiveArm = nextLiveTurnArm(liveArm, paneId, turnEndView && connected && visible, turnOnlyInTerminal(state.conversationSource, agentStatus), newestReads.current, settledRead, writtenKey); if (nextLiveArm !== liveArm) setLiveArm(nextLiveArm); - const liveTurnOpen = turnRunning && liveTurnShown(nextLiveArm, writtenKey); + const liveTurnOpen = liveTurnShown(nextLiveArm, writtenKey); const [error, setError] = useState(null); const [errorStatus, setErrorStatus] = useState(null); /** reads failed in a row: a slow PC is waited for this many times before the chat alarms */ @@ -1583,7 +1597,7 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish if (!continued) { pagingAway.current = false; stickToBottom.current = true; lastRowOffset.current = null; lastScrollHeight.current = 0; setNewMessages(false); setAway(false); } } // a 304 hands back the answer already shown: nothing to compare or lay out again - if (conversation === lastAnswer.current) { setError(null); setErrorStatus(null); setFailures(0); setReadPresentation(proved ? readKey : undefined); setSettledRead({ pane: paneId, read: started }); return; } + if (conversation === lastAnswer.current) { setError(null); setErrorStatus(null); setFailures(0); setReadPresentation(proved ? readKey : undefined); setSettledRead({ pane: paneId, read: started, ok: true }); return; } // The newest page moved past the held start: the turns in between join the older // pages and the newest page is held from its new start, so no poll reads more than a page. const previous = lastAnswer.current; @@ -1620,9 +1634,10 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish } showAnswer(conversation, next); setReadPresentation(proved ? readKey : undefined); - setSettledRead({ pane: paneId, read: started }); + setSettledRead({ pane: paneId, read: started, ok: true }); } catch (cause) { if (cancelled || generation !== olderGeneration.current) return; + setSettledRead({ pane: paneId, read: started, ok: false }); setLoaded(true); setError(userFailure(cause)); setErrorStatus(cause instanceof ApiError ? cause.status : null);