fix(tui): synchronize shared session clients

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 2f78f7fbc5
commit 9e47d8106c
25 changed files with 803 additions and 101 deletions
+79 -3
View File
@@ -167,7 +167,7 @@ describe("NanobotTui layout", () => {
expect(sent).toEqual(["你好"])
})
test("inserts a newline without sending and gives the composer breathing room", async () => {
test("inserts newlines with Shift+Enter and the universal Ctrl+J fallback", async () => {
const sent: string[] = []
setup = await createRenderer({
width: 72,
@@ -184,11 +184,13 @@ describe("NanobotTui layout", () => {
}
await setup.mockInput.typeText("first")
setup.mockInput.pressKey("j", { ctrl: true })
setup.mockInput.pressEnter({ shift: true })
await setup.mockInput.typeText("second")
setup.mockInput.pressKey("j", { ctrl: true })
await setup.mockInput.typeText("third")
await setup.flush()
expect(ui.composer.plainText).toBe("first\nsecond")
expect(ui.composer.plainText).toBe("first\nsecond\nthird")
expect(sent).toEqual([])
expect(ui.composerFrame.height).toBeGreaterThanOrEqual(3)
})
@@ -309,6 +311,80 @@ describe("NanobotTui layout", () => {
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBeTrue()
})
test("projects user turns from another terminal without duplicating replayed history", async () => {
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
const app = mount(setup)
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
const first = {
event: "user_message" as const,
chat_id: "chat",
text: "hello from terminal A",
turn_id: "remote-turn",
active_turn_id: "remote-turn",
starts_turn: true,
started_at: 1_700_000_000,
media_urls: [{
kind: "file" as const,
url: "/api/media/sig/report",
name: "report.pdf",
}],
}
app.accept(first)
app.accept(first)
app.accept({
event: "user_message",
chat_id: "chat",
text: "one more remote detail",
turn_id: "remote-steer",
active_turn_id: "remote-turn",
starts_turn: false,
})
await setup.flush()
const state = app as unknown as { activeTurn: boolean; activeTurnId: string | null }
const frame = setup.captureCharFrame()
expect(occurrences(frame, "hello from terminal A")).toBe(1)
expect(occurrences(frame, "Attachments: report.pdf")).toBe(1)
expect(occurrences(frame, "one more remote detail")).toBe(1)
expect(state.activeTurn).toBeTrue()
expect(state.activeTurnId).toBe("remote-turn")
app.accept({ event: "turn_end", chat_id: "chat", turn_id: "remote-turn" })
expect(state.activeTurn).toBeFalse()
})
test("reconciles simultaneous submits to the gateway-owned turn", async () => {
const sent: string[] = []
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
const app = mount(setup, sent)
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
const ui = app as unknown as {
composer: TextareaRenderable
activeTurn: boolean
activeTurnId: string | null
}
ui.composer.setText("submitted from terminal B")
ui.composer.submit()
await waitUntil(() => sent.length === 1)
expect(ui.activeTurnId).toBe("turn")
app.accept({
event: "message_accepted",
chat_id: "chat",
turn_id: "turn",
active_turn_id: "terminal-a-turn",
starts_turn: false,
started_at: 1_700_000_000,
})
expect(ui.activeTurn).toBeTrue()
expect(ui.activeTurnId).toBe("terminal-a-turn")
})
test("recalls submitted prompts without stealing multiline cursor movement", async () => {
const sent: string[] = []
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
+41 -3
View File
@@ -586,6 +586,7 @@ export class NanobotTui {
this.renderer.keyInput.on("keypress", this.handleKey)
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleTheme)
this.renderer.on(CliRenderEvents.CAPABILITIES, this.handleCapabilities)
this.renderer.on(CliRenderEvents.RESIZE, this.handleResize)
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
this.renderer.console.onCopySelection = (text) => void this.copySelection(text)
@@ -720,12 +721,17 @@ export class NanobotTui {
this.commandMenu.hide()
this.mentionMenu.hide()
this.recordPrompt(prompt.content)
this.transcript.user(prompt.content)
this.transcript.user(prompt.content, turnId)
if (steering) {
this.status.content = `Steering current turn${this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""}`
this.updateMeta()
return true
}
this.beginTurn(turnId)
return true
}
private beginTurn(turnId: string | null, startedAt?: number): void {
this.activeTurnId = turnId
this.readyDetail = ""
this.finalMessage = ""
@@ -733,8 +739,23 @@ export class NanobotTui {
this.lastProgress = ""
this.activeLabel = "Thinking"
this.currentFileEdits = []
this.setActive(true)
return true
this.setActive(true, startedAt)
}
private reconcileTurnOwnership(event: {
turn_id?: string
active_turn_id?: string
starts_turn?: boolean
started_at?: number
}): void {
if (event.active_turn_id && this.activeTurn) {
this.activeTurnId = event.active_turn_id
} else if (event.active_turn_id || (event.starts_turn && !this.activeTurn)) {
this.beginTurn(
event.active_turn_id || event.turn_id || null,
typeof event.started_at === "number" ? event.started_at * 1000 : undefined,
)
}
}
accept(event: InboundEvent): void {
@@ -777,7 +798,18 @@ export class NanobotTui {
switch (event.event) {
case "message_accepted":
this.reconcileTurnOwnership(event)
return
case "user_message": {
const attachments = event.media_urls?.map((media) => media.name).filter(Boolean) || []
const content = [
event.text,
attachments.length ? `Attachments: ${attachments.join(", ")}` : "",
].filter(Boolean).join("\n")
if (this.transcript.user(content, event.turn_id)) this.recordPrompt(event.text)
this.reconcileTurnOwnership(event)
return
}
case "delta":
this.setActive(true)
this.activeLabel = "Writing"
@@ -1277,6 +1309,10 @@ export class NanobotTui {
this.applyTheme(mode)
}
private handleCapabilities = (): void => {
this.updateMeta()
}
private resolveThemeMode(detected: ThemeMode | null): ThemeMode {
return this.options.theme === "auto" ? detected ?? "dark" : this.options.theme
}
@@ -1328,6 +1364,8 @@ export class NanobotTui {
mode,
this.renderer.width,
footerHintTheme(this.palette),
process.platform,
Boolean(this.renderer.capabilities?.kitty_keyboard),
)
}
+19 -2
View File
@@ -22,12 +22,29 @@ describe("footerHints", () => {
})
test("adapts the active-turn vocabulary to available width", () => {
const wide = contextualFooterHints("active", 100, theme)
const compact = contextualFooterHints("active", 72, theme)
const wide = contextualFooterHints("active", 100, theme, "linux")
const compact = contextualFooterHints("active", 72, theme, "linux")
expect(wide.chunks.map(({ text }) => text).join(""))
.toBe("enter steer · tab queue · alt+↑ edit · ctrl+c stop")
expect(compact.chunks.map(({ text }) => text).join(""))
.toBe("enter steer · tab queue · ctrl+c stop")
})
test("uses the native Option symbol on macOS", () => {
const result = contextualFooterHints("active", 100, theme, "darwin")
expect(result.chunks.map(({ text }) => text).join(""))
.toBe("enter steer · tab queue · ⌥↑ edit · ctrl+c stop")
})
test("advertises Shift+Enter when the terminal can distinguish it", () => {
const enhanced = contextualFooterHints("ready", 80, theme, "darwin", true)
const legacy = contextualFooterHints("ready", 80, theme, "darwin", false)
expect(enhanced.chunks.map(({ text }) => text).join(""))
.toBe("enter send · shift+enter newline · ctrl+c stop")
expect(legacy.chunks.map(({ text }) => text).join(""))
.toBe("enter send · ctrl+j newline · ctrl+c stop")
})
})
+16 -6
View File
@@ -1,5 +1,7 @@
import { RGBA, StyledText, TextAttributes, type TextChunk } from "@opentui/core"
import { optionArrowUp } from "./platform-keys"
export interface FooterHint {
key: string
label: string
@@ -27,8 +29,10 @@ export function contextualFooterHints(
mode: FooterMode,
width: number,
theme: FooterHintTheme,
platform: string = process.platform,
shiftedEnter = false,
): StyledText {
return footerHints(hintsFor(mode, width), theme)
return footerHints(hintsFor(mode, width, platform, shiftedEnter), theme)
}
/** Give shortcuts visual hierarchy without turning the footer into a toolbar. */
@@ -43,12 +47,17 @@ export function footerHints(hints: readonly FooterHint[], theme: FooterHintTheme
return new StyledText(chunks)
}
function hintsFor(mode: FooterMode, width: number): FooterHint[] {
function hintsFor(
mode: FooterMode,
width: number,
platform: string,
shiftedEnter: boolean,
): FooterHint[] {
if (mode === "mention") return width >= 64
? [hint("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")]
: [hint("enter", "insert"), hint("esc", "close")]
if (mode === "active") return width >= 96
? [hint("enter", "steer"), hint("tab", "queue"), hint("alt+↑", "edit"), stopHint()]
? [hint("enter", "steer"), hint("tab", "queue"), hint(optionArrowUp(platform), "edit"), stopHint()]
: width >= 64 ? [hint("enter", "steer"), hint("tab", "queue"), stopHint()] : []
if (mode === "branch") return width >= 64
? [hint("type", "filter"), hint("↑↓", "choose"), hint("enter", "branch"), hint("esc", "close")]
@@ -63,15 +72,16 @@ function hintsFor(mode: FooterMode, width: number): FooterHint[] {
if (mode === "history") return width >= 72
? [hint("ctrl+end", "latest"), hint("pgup/pgdn", "scroll")]
: width >= 48 ? [hint("ctrl+end", "latest")] : []
const newline = shiftedEnter ? "shift+enter" : "ctrl+j"
if (width >= 112) return [
hint("enter", "send"),
hint("ctrl+j", "newline"),
hint(newline, "newline"),
hint("pgup/pgdn", "scroll"),
hint("ctrl+o", "tools"),
stopHint(),
]
if (width >= 72) return [hint("enter", "send"), hint("ctrl+j", "newline"), stopHint()]
return width >= 48 ? [hint("enter", "send"), hint("ctrl+j", "newline")] : []
if (width >= 72) return [hint("enter", "send"), hint(newline, "newline"), stopHint()]
return width >= 48 ? [hint("enter", "send"), hint(newline, "newline")] : []
}
function hint(key: string, label: string): FooterHint {
+3
View File
@@ -0,0 +1,3 @@
export function optionArrowUp(platform: string = process.platform): string {
return platform === "darwin" ? "⌥↑" : "alt+↑"
}
+34 -4
View File
@@ -38,11 +38,25 @@ class FakeSocket {
describe("gateway protocol", () => {
test("represents lifecycle frames without browser state", () => {
const events: InboundEvent[] = [
{
event: "user_message",
chat_id: "one",
text: "question",
turn_id: "turn-one",
active_turn_id: "turn-one",
starts_turn: true,
media_urls: [{ kind: "file", url: "/api/media/sig/file", name: "report.pdf" }],
},
{ event: "delta", chat_id: "one", text: "hello" },
{ event: "stream_end", chat_id: "one", resuming: false },
{ event: "turn_end", chat_id: "one", latency_ms: 12 },
]
expect(events.map((event) => event.event)).toEqual(["delta", "stream_end", "turn_end"])
expect(events.map((event) => event.event)).toEqual([
"user_message",
"delta",
"stream_end",
"turn_end",
])
})
test("attaches and sends turns through the gateway envelope", () => {
@@ -139,6 +153,13 @@ describe("gateway protocol", () => {
if (!socket) throw new Error("socket was not created")
socket.emit("message", { data: "[]" })
socket.emit("message", { data: JSON.stringify({ event: "delta", chat_id: "one" }) })
socket.emit("message", {
data: JSON.stringify({
event: "user_message",
chat_id: "one",
text: "missing lifecycle",
}),
})
socket.emit("message", {
data: JSON.stringify({
event: "message",
@@ -153,6 +174,15 @@ describe("gateway protocol", () => {
socket.emit("message", {
data: JSON.stringify({ event: "session_updated", chat_id: "one", scope: 42 }),
})
socket.emit("message", {
data: JSON.stringify({
event: "user_message",
chat_id: "one",
text: "bad media",
starts_turn: false,
media_urls: [{ kind: "archive", url: "/api/media/sig/file" }],
}),
})
socket.emit("message", {
data: JSON.stringify({ event: "attached", chat_id: "one", model_preset: 42 }),
})
@@ -162,7 +192,7 @@ describe("gateway protocol", () => {
socket.emit("message", { data: JSON.stringify({ event: "future_gateway_event" }) })
socket.emit("message", { data: JSON.stringify({ event: "error", detail: "global failure" }) })
expect(statuses).toContain("error:gateway sent an invalid event")
expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(6)
expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(8)
expect(events).toContainEqual({
event: "session_updated",
chat_id: "one",
@@ -274,7 +304,7 @@ describe("gateway protocol", () => {
requested = String(input)
return Promise.resolve(new Response(JSON.stringify({
messages: [
{ role: "user", content: "hello" },
{ role: "user", content: "hello", turnId: "turn-1" },
{
role: "tool",
kind: "trace",
@@ -293,7 +323,7 @@ describe("gateway protocol", () => {
const history = await fetchHistory("http://nanobot.test", "token", "chat", "newer-page")
expect(history).toEqual({
messages: [
{ role: "user", content: "hello" },
{ role: "user", content: "hello", turnId: "turn-1" },
{
role: "activity",
content: "read_file",
+56 -3
View File
@@ -36,6 +36,12 @@ export interface FileDiff {
text?: string
}
export interface MediaAttachment {
kind: "image" | "video" | "file"
url: string
name?: string
}
export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string }
| {
@@ -44,7 +50,24 @@ export type InboundEvent =
model_preset?: string | null
usage?: TokenUsage
}
| { event: "message_accepted"; chat_id: string; turn_id: string }
| {
event: "message_accepted"
chat_id: string
turn_id: string
starts_turn?: boolean
active_turn_id?: string
started_at?: number
}
| {
event: "user_message"
chat_id: string
text: string
turn_id?: string
active_turn_id?: string
starts_turn: boolean
started_at?: number
media_urls?: MediaAttachment[]
}
| {
event: "message"
chat_id: string
@@ -119,6 +142,7 @@ export interface ClientOptions {
export interface HistoryMessage {
role: "user" | "assistant" | "activity"
content: string
turnId?: string
toolEvents?: ToolProgressEvent[]
fileEdits?: FileEditEvent[]
forkIndex?: number
@@ -213,6 +237,7 @@ const SLASH_COMMAND_LIFECYCLES = new Set([
const CHAT_EVENTS = new Set([
"attached",
"message_accepted",
"user_message",
"message",
"file_edit",
"delta",
@@ -283,6 +308,13 @@ function isTokenUsage(value: unknown): value is TokenUsage {
].every((key) => optional(value[key], "number"))
}
function isMediaAttachment(value: unknown): value is MediaAttachment {
return isRecord(value)
&& (value.kind === "image" || value.kind === "video" || value.kind === "file")
&& typeof value.url === "string"
&& optional(value.name, "string")
}
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
if (!isRecord(value)) return null
const record = value
@@ -315,9 +347,26 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
&& typeof record.model_preset !== "string")
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
) return null
if (["message", "delta", "reasoning_delta"].includes(name) && typeof record.text !== "string") {
if (
["user_message", "message", "delta", "reasoning_delta"].includes(name)
&& typeof record.text !== "string"
) {
return null
}
if (
["message_accepted", "user_message"].includes(name)
&& (
(name === "user_message" && typeof record.starts_turn !== "boolean")
|| !optional(record.starts_turn, "boolean")
|| !optional(record.active_turn_id, "string")
|| !optional(record.started_at, "number")
)
) return null
if (
name === "user_message"
&& record.media_urls !== undefined
&& (!Array.isArray(record.media_urls) || !record.media_urls.every(isMediaAttachment))
) return null
if (
name === "message"
&& record.tool_events !== undefined
@@ -411,7 +460,11 @@ export async function fetchHistory(
}
if (role === "user") {
userIndex += 1
messages.push({ role: "user", content })
messages.push({
role: "user",
content,
...(typeof message.turnId === "string" ? { turnId: message.turnId } : {}),
})
} else {
messages.push({ role: "assistant", content, forkIndex: userIndex })
}
+2 -1
View File
@@ -17,7 +17,7 @@ describe("QueuePreview", () => {
accent: "#EF8E30",
muted: "#A1A1AA",
faint: "#71717A",
})
}, "darwin")
setup.renderer.root.add(preview.root)
preview.update(["first\nline", "second", "third", "fourth"])
@@ -25,6 +25,7 @@ describe("QueuePreview", () => {
const frame = setup.captureCharFrame()
expect(frame).toContain("Queued next 4")
expect(frame).toContain("⌥↑ edit last")
expect(frame).not.toContain("first line")
expect(frame).toContain("↳ second")
expect(frame).toContain("↳ fourth")
+6 -2
View File
@@ -8,6 +8,8 @@ import {
type TextChunk,
} from "@opentui/core"
import { optionArrowUp } from "./platform-keys"
export interface QueuePreviewTheme {
accent: string
muted: string
@@ -21,11 +23,13 @@ export class QueuePreview {
readonly root: BoxRenderable
private readonly header: TextRenderable
private readonly rows: TextRenderable[]
private readonly editKey: string
private theme: QueuePreviewTheme
private messages: readonly string[] = []
constructor(renderer: CliRenderer, theme: QueuePreviewTheme) {
constructor(renderer: CliRenderer, theme: QueuePreviewTheme, platform: string = process.platform) {
this.theme = theme
this.editKey = optionArrowUp(platform)
this.root = new BoxRenderable(renderer, {
id: "nanobot-tui-queue-preview",
width: "100%",
@@ -64,7 +68,7 @@ export class QueuePreview {
this.header.content = new StyledText([
chunk("Queued next", this.theme.accent, true),
chunk(` ${messages.length}`, this.theme.faint),
chunk(" · alt+↑ edit last", this.theme.faint),
chunk(` · ${this.editKey} edit last`, this.theme.faint),
])
this.rows.forEach((row, index) => {
const message = visible[index]
+9 -2
View File
@@ -59,6 +59,7 @@ export class Transcript {
private readonly activities = new Set<Activity>()
private readonly frames = new Set<BoxRenderable>()
private readonly userRows = new Set<BoxRenderable>()
private readonly userTurnIds = new Set<string>()
private wrote = false
private nextId = 0
private navigation: TranscriptNavigation = { awayFromBottom: false, unseenOutput: false }
@@ -150,6 +151,7 @@ export class Transcript {
this.activities.clear()
this.frames.clear()
this.userRows.clear()
this.userTurnIds.clear()
this.wrote = false
this.nextId = 0
this.navigation = { awayFromBottom: false, unseenOutput: false }
@@ -160,7 +162,7 @@ export class Transcript {
history(messages: HistoryMessage[]): void {
for (const message of messages) {
if (message.role === "user") this.user(message.content)
if (message.role === "user") this.user(message.content, message.turnId)
else if (message.role === "assistant") this.assistant(message.content)
else if (message.fileEdits?.length) this.fileEdits(message.fileEdits)
else this.progress(message.content, message.toolEvents)
@@ -175,7 +177,9 @@ export class Transcript {
let index = 1 // Keep the launch header first.
for (const message of messages) {
if (message.role === "user") {
if (message.turnId && this.userTurnIds.has(message.turnId)) continue
this.writeRole("", message.content, "user", index++)
if (message.turnId) this.userTurnIds.add(message.turnId)
} else if (message.role === "assistant") {
this.writeMarkdown(message.content, false, index++)
} else {
@@ -200,10 +204,13 @@ export class Transcript {
return this.root.scrollTop <= 0
}
user(content: string): void {
user(content: string, turnId?: string): boolean {
if (turnId && this.userTurnIds.has(turnId)) return false
this.noteOutput()
this.finishActivity()
this.writeRole("", content, "user")
if (turnId) this.userTurnIds.add(turnId)
return true
}
assistant(content: string): void {