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
+14 -5
View File
@@ -9,7 +9,7 @@ bun run --cwd tui test
bun run --cwd tui build
```
`nanobot agent` launches this client, attaches to an existing local gateway or leases one for the process lifetime, and passes an authenticated local endpoint through environment variables. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified sidecar. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
`nanobot agent` launches this client, attaches to the shared local gateway or starts it in the background, and passes an authenticated local endpoint through environment variables. Exiting one client leaves that gateway available to other terminals and the WebUI; stop it explicitly with `nanobot gateway stop`. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified sidecar. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
The renderer uses OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
@@ -22,10 +22,11 @@ to move, `Tab` to complete, and `Esc` to close the menu.
Type `@` to complete installed CLI apps, configured MCP servers, or saved sessions through the
same gateway metadata used by the WebUI. While nanobot is working, `Enter` steers the current
turn, `Tab` queues a follow-up for the next turn, and `Alt+Up` returns the latest queued message
to the composer for editing. The pending queue stays visible above the composer.
Use `Ctrl+J` for a newline; `Shift+Enter`, `Alt+Enter`, and `Ctrl+Enter` are also accepted when
the terminal can distinguish them.
turn, `Tab` queues a follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on
Windows/Linux) returns the latest queued message to the composer for editing. The pending queue
stays visible above the composer.
Use `Shift+Enter` for a newline; `Ctrl+J` is the universal fallback when a terminal cannot
distinguish modified Enter keys. `Alt+Enter` and `Ctrl+Enter` are also accepted when distinguishable.
Unsent prompts return to the composer if the turn stops or fails.
Use `/sessions` to search and switch persisted conversations without leaving the terminal.
@@ -34,6 +35,14 @@ command keeps its cross-channel behavior and resets the current chat. The next l
the last session unless `--session` selects another one. When earlier transcript pages exist,
press `PageUp` at the top to load them in place.
The native client accepts bare WebSocket chat IDs or `websocket:<id>` selectors. Use
`nanobot agent --classic --session <channel:id>` to resume a session owned by another channel;
the TUI never silently maps one channel's identity into the WebSocket namespace.
Sessions are live across clients: if two terminals or the WebUI attach to the same session, an
accepted user message and the resulting agent/tool stream appear on every attached client while
the gateway executes the input only once.
`/branch` creates a new saved conversation from a completed reply without changing the source
session. The picker uses durable history indices, so paginated transcripts branch at the selected
turn rather than the currently visible row.
+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 {