mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 10:11:46 +03:00
fix(tui): synchronize shared session clients
This commit is contained in:
+79
-3
@@ -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
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function optionArrowUp(platform: string = process.platform): string {
|
||||
return platform === "darwin" ? "⌥↑" : "alt+↑"
|
||||
}
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user