mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
perf(tui): keep long streams responsive
This commit is contained in:
+43
-12
@@ -651,17 +651,25 @@ describe("NanobotTui layout", () => {
|
|||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
const sent: string[] = []
|
const sent: string[] = []
|
||||||
const scopes: WorkspaceScopePayload[] = []
|
const scopes: WorkspaceScopePayload[] = []
|
||||||
|
let settingsRequests = 0
|
||||||
|
let workspaceRequests = 0
|
||||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||||
const url = String(input)
|
const url = String(input)
|
||||||
if (url.endsWith("/api/settings")) return new Response(JSON.stringify({
|
if (url.endsWith("/api/settings")) {
|
||||||
model_presets: [
|
settingsRequests += 1
|
||||||
{ name: "default", model: "test/model" },
|
return new Response(JSON.stringify({
|
||||||
{ name: "fast", model: "fast/model" },
|
model_presets: [
|
||||||
],
|
{ name: "default", model: "test/model" },
|
||||||
}))
|
{ name: "fast", model: "fast/model" },
|
||||||
if (url.endsWith("/api/workspaces")) return new Response(JSON.stringify({
|
],
|
||||||
controls: { can_use_full_access: true },
|
}))
|
||||||
}))
|
}
|
||||||
|
if (url.endsWith("/api/workspaces")) {
|
||||||
|
workspaceRequests += 1
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
controls: { can_use_full_access: true },
|
||||||
|
}))
|
||||||
|
}
|
||||||
return new Response(JSON.stringify({ sessions: [] }))
|
return new Response(JSON.stringify({ sessions: [] }))
|
||||||
}) as typeof fetch
|
}) as typeof fetch
|
||||||
setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" })
|
||||||
@@ -756,6 +764,8 @@ describe("NanobotTui layout", () => {
|
|||||||
await setup.mockMouse.click(ui.status.x, ui.status.y)
|
await setup.mockMouse.click(ui.status.x, ui.status.y)
|
||||||
expect(ui.runtimeControls.visible).toBe(false)
|
expect(ui.runtimeControls.visible).toBe(false)
|
||||||
expect(ui.composer.focused).toBe(true)
|
expect(ui.composer.focused).toBe(true)
|
||||||
|
expect(settingsRequests).toBe(1)
|
||||||
|
expect(workspaceRequests).toBe(1)
|
||||||
|
|
||||||
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBe(true)
|
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBe(true)
|
||||||
app.accept({ event: "goal_status", chat_id: "chat", status: "idle" })
|
app.accept({ event: "goal_status", chat_id: "chat", status: "idle" })
|
||||||
@@ -1475,7 +1485,7 @@ describe("NanobotTui layout", () => {
|
|||||||
expect(internals.palette.referenceBackground).toBe("#FAFAFA")
|
expect(internals.palette.referenceBackground).toBe("#FAFAFA")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("waits for automatic terminal detection before connecting or painting", async () => {
|
test("overlaps automatic terminal detection with connection startup", async () => {
|
||||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||||
let connected = false
|
let connected = false
|
||||||
let resolveMode: (mode: "light") => void = () => undefined
|
let resolveMode: (mode: "light") => void = () => undefined
|
||||||
@@ -1494,11 +1504,10 @@ describe("NanobotTui layout", () => {
|
|||||||
|
|
||||||
const starting = app.start()
|
const starting = app.start()
|
||||||
await Bun.sleep(1)
|
await Bun.sleep(1)
|
||||||
expect(connected).toBe(false)
|
expect(connected).toBe(true)
|
||||||
|
|
||||||
resolveMode("light")
|
resolveMode("light")
|
||||||
await starting
|
await starting
|
||||||
expect(connected).toBe(true)
|
|
||||||
expect((app as unknown as { palette: { referenceBackground: string } }).palette.referenceBackground).toBe("#FAFAFA")
|
expect((app as unknown as { palette: { referenceBackground: string } }).palette.referenceBackground).toBe("#FAFAFA")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1585,6 +1594,28 @@ describe("NanobotTui layout", () => {
|
|||||||
expect(frame).not.toContain("draft signed://expired")
|
expect(frame).not.toContain("draft signed://expired")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("paints the first token immediately and coalesces the rest per frame", async () => {
|
||||||
|
setup = await createRenderer({ width: 80, height: 20, screenMode: "alternate-screen" })
|
||||||
|
const app = mount(setup)
|
||||||
|
app.accept({ event: "delta", chat_id: "chat", text: "first" })
|
||||||
|
const transcript = (app as unknown as {
|
||||||
|
transcript: {
|
||||||
|
live: { markdown: { content: string; streaming: boolean } } | null
|
||||||
|
}
|
||||||
|
}).transcript
|
||||||
|
const markdown = transcript.live?.markdown
|
||||||
|
expect(markdown?.content).toBe("first")
|
||||||
|
|
||||||
|
for (let index = 0; index < 1_000; index += 1) {
|
||||||
|
app.accept({ event: "delta", chat_id: "chat", text: " token" })
|
||||||
|
}
|
||||||
|
expect(markdown?.content).toBe("first")
|
||||||
|
|
||||||
|
app.accept({ event: "stream_end", chat_id: "chat" })
|
||||||
|
expect(markdown?.content).toBe(`first${" token".repeat(1_000)}`)
|
||||||
|
expect(markdown?.streaming).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test("copies full-screen selections through OSC 52", async () => {
|
test("copies full-screen selections through OSC 52", async () => {
|
||||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||||
const app = mount(setup)
|
const app = mount(setup)
|
||||||
|
|||||||
+8
-4
@@ -711,6 +711,14 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
|
// Network setup and small menu payloads do not depend on terminal colors.
|
||||||
|
// Start them while OSC theme detection is in flight instead of serializing
|
||||||
|
// up to one second of otherwise independent startup work.
|
||||||
|
this.host.reportState("unknown", "Connecting")
|
||||||
|
this.client.connect()
|
||||||
|
void this.loadCommands()
|
||||||
|
void this.loadMentions()
|
||||||
|
this.runtimeControls.preload()
|
||||||
// OpenTUI learns the real terminal background through OSC 10/11. Wait for
|
// OpenTUI learns the real terminal background through OSC 10/11. Wait for
|
||||||
// that bounded probe before first paint, as OpenCode does, so a light
|
// that bounded probe before first paint, as OpenCode does, so a light
|
||||||
// terminal does not briefly render the dark palette. The app already owns
|
// terminal does not briefly render the dark palette. The app already owns
|
||||||
@@ -720,10 +728,6 @@ export class NanobotTui {
|
|||||||
if (this.options.theme === "auto" && this.renderer.themeMode) {
|
if (this.options.theme === "auto" && this.renderer.themeMode) {
|
||||||
this.applyTheme(this.renderer.themeMode)
|
this.applyTheme(this.renderer.themeMode)
|
||||||
}
|
}
|
||||||
this.host.reportState("unknown", "Connecting")
|
|
||||||
this.client.connect()
|
|
||||||
void this.loadCommands()
|
|
||||||
void this.loadMentions()
|
|
||||||
this.renderer.start()
|
this.renderer.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { createTuiHost, currentGitBranch } from "./host"
|
import { createTuiHost, currentGitBranch } from "./host"
|
||||||
|
|
||||||
async function settle(): Promise<void> {
|
async function settle(): Promise<void> {
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(40)
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +74,9 @@ describe("TUI host integration", () => {
|
|||||||
host.reportMetadata({ model: "gpt", branch: "" })
|
host.reportMetadata({ model: "gpt", branch: "" })
|
||||||
await settle()
|
await settle()
|
||||||
|
|
||||||
expect(commands).toHaveLength(2)
|
expect(commands).toHaveLength(1)
|
||||||
expect(commands[1]).toContain("--clear-token")
|
expect(commands[0]).toContain("model=gpt")
|
||||||
expect(commands[1]).toContain("branch")
|
expect(commands[0]).toContain("--clear-token")
|
||||||
expect(commands[1]).not.toContain("model=gpt")
|
expect(commands[0]).toContain("branch")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+22
-4
@@ -36,6 +36,7 @@ const AGENT = "nanobot"
|
|||||||
const LIFECYCLE_SOURCE = "nanobot:tui"
|
const LIFECYCLE_SOURCE = "nanobot:tui"
|
||||||
const METADATA_SOURCE = "nanobot:tui:metadata"
|
const METADATA_SOURCE = "nanobot:tui:metadata"
|
||||||
const METADATA_KEYS = ["model", "branch", "workspace", "task", "action"] as const
|
const METADATA_KEYS = ["model", "branch", "workspace", "task", "action"] as const
|
||||||
|
const METADATA_FLUSH_MS = 32
|
||||||
|
|
||||||
class StandaloneHost implements TuiHost {
|
class StandaloneHost implements TuiHost {
|
||||||
readonly hosted = false
|
readonly hosted = false
|
||||||
@@ -52,6 +53,8 @@ class HerdrHost implements TuiHost {
|
|||||||
private lastState = ""
|
private lastState = ""
|
||||||
private lastSession = ""
|
private lastSession = ""
|
||||||
private metadata: HostMetadata = {}
|
private metadata: HostMetadata = {}
|
||||||
|
private readonly pendingMetadata = new Set<typeof METADATA_KEYS[number]>()
|
||||||
|
private metadataTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
private queue: Promise<void> = Promise.resolve()
|
private queue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -66,6 +69,9 @@ class HerdrHost implements TuiHost {
|
|||||||
const fingerprint = `${state}\0${cleanMessage}\0${this.lastSession}`
|
const fingerprint = `${state}\0${cleanMessage}\0${this.lastSession}`
|
||||||
if (fingerprint === this.lastState) return
|
if (fingerprint === this.lastState) return
|
||||||
this.lastState = fingerprint
|
this.lastState = fingerprint
|
||||||
|
// Preserve causal ordering when a semantic state transition follows a
|
||||||
|
// pending metadata snapshot; repeated working heartbeats still stay free.
|
||||||
|
this.flushMetadata()
|
||||||
const args = [
|
const args = [
|
||||||
"pane", "report-agent", this.paneId,
|
"pane", "report-agent", this.paneId,
|
||||||
"--source", LIFECYCLE_SOURCE,
|
"--source", LIFECYCLE_SOURCE,
|
||||||
@@ -84,6 +90,7 @@ class HerdrHost implements TuiHost {
|
|||||||
if (!cleanSession || cleanSession === this.lastSession) return
|
if (!cleanSession || cleanSession === this.lastSession) return
|
||||||
this.lastSession = cleanSession
|
this.lastSession = cleanSession
|
||||||
this.lastState = ""
|
this.lastState = ""
|
||||||
|
this.flushMetadata()
|
||||||
this.enqueue([
|
this.enqueue([
|
||||||
"pane", "report-agent-session", this.paneId,
|
"pane", "report-agent-session", this.paneId,
|
||||||
"--source", LIFECYCLE_SOURCE,
|
"--source", LIFECYCLE_SOURCE,
|
||||||
@@ -95,14 +102,22 @@ class HerdrHost implements TuiHost {
|
|||||||
|
|
||||||
reportMetadata(next: HostMetadata): void {
|
reportMetadata(next: HostMetadata): void {
|
||||||
if (this.released) return
|
if (this.released) return
|
||||||
const changed: Array<[typeof METADATA_KEYS[number], string]> = []
|
|
||||||
for (const key of METADATA_KEYS) {
|
for (const key of METADATA_KEYS) {
|
||||||
|
if (!(key in next)) continue
|
||||||
const value = normalize(next[key])
|
const value = normalize(next[key])
|
||||||
if (value === normalize(this.metadata[key])) continue
|
if (value === normalize(this.metadata[key])) continue
|
||||||
changed.push([key, value])
|
this.pendingMetadata.add(key)
|
||||||
}
|
}
|
||||||
if (!changed.length) return
|
if (!this.pendingMetadata.size) return
|
||||||
this.metadata = { ...this.metadata, ...next }
|
this.metadata = { ...this.metadata, ...next }
|
||||||
|
if (this.metadataTimer) return
|
||||||
|
this.metadataTimer = setTimeout(() => this.flushMetadata(), METADATA_FLUSH_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushMetadata(): void {
|
||||||
|
if (this.metadataTimer) clearTimeout(this.metadataTimer)
|
||||||
|
this.metadataTimer = null
|
||||||
|
if (!this.pendingMetadata.size) return
|
||||||
const args = [
|
const args = [
|
||||||
"pane", "report-metadata", this.paneId,
|
"pane", "report-metadata", this.paneId,
|
||||||
"--source", METADATA_SOURCE,
|
"--source", METADATA_SOURCE,
|
||||||
@@ -113,14 +128,17 @@ class HerdrHost implements TuiHost {
|
|||||||
const task = normalize(this.metadata.task)
|
const task = normalize(this.metadata.task)
|
||||||
args.push(task ? "--title" : "--clear-title")
|
args.push(task ? "--title" : "--clear-title")
|
||||||
if (task) args.push(task)
|
if (task) args.push(task)
|
||||||
for (const [key, value] of changed) {
|
for (const key of this.pendingMetadata) {
|
||||||
|
const value = normalize(this.metadata[key])
|
||||||
args.push(value ? "--token" : "--clear-token", value ? `${key}=${value}` : key)
|
args.push(value ? "--token" : "--clear-token", value ? `${key}=${value}` : key)
|
||||||
}
|
}
|
||||||
|
this.pendingMetadata.clear()
|
||||||
this.enqueue(args)
|
this.enqueue(args)
|
||||||
}
|
}
|
||||||
|
|
||||||
release(): void {
|
release(): void {
|
||||||
if (this.released) return
|
if (this.released) return
|
||||||
|
this.flushMetadata()
|
||||||
this.released = true
|
this.released = true
|
||||||
const clear = [
|
const clear = [
|
||||||
"pane", "report-metadata", this.paneId,
|
"pane", "report-metadata", this.paneId,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type Choice =
|
|||||||
| { kind: "model"; name: string; label: string; detail: string }
|
| { kind: "model"; name: string; label: string; detail: string }
|
||||||
| { kind: "access"; mode: "restricted" | "full"; label: string; detail: string }
|
| { kind: "access"; mode: "restricted" | "full"; label: string; detail: string }
|
||||||
|
|
||||||
|
const CONTROLS_CACHE_MS = 10_000
|
||||||
|
|
||||||
interface RuntimeControlsOptions {
|
interface RuntimeControlsOptions {
|
||||||
apiUrl: string
|
apiUrl: string
|
||||||
apiToken: string
|
apiToken: string
|
||||||
@@ -49,6 +51,7 @@ export class RuntimeControls {
|
|||||||
private modelPresets: Array<{ name: string; model: string }>
|
private modelPresets: Array<{ name: string; model: string }>
|
||||||
private canUseFullAccess: boolean
|
private canUseFullAccess: boolean
|
||||||
private controlsLoaded = false
|
private controlsLoaded = false
|
||||||
|
private controlsLoadedAt = 0
|
||||||
private controlsPromise: Promise<void> | null = null
|
private controlsPromise: Promise<void> | null = null
|
||||||
private scope: WorkspaceScopePayload
|
private scope: WorkspaceScopePayload
|
||||||
|
|
||||||
@@ -104,6 +107,11 @@ export class RuntimeControls {
|
|||||||
this.contextText.content = text
|
this.contextText.content = text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Warm the small settings payload while the user is reading the first frame. */
|
||||||
|
preload(): void {
|
||||||
|
void this.load().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
updateWorkspaceScope(scope: WorkspaceScopePayload): void {
|
updateWorkspaceScope(scope: WorkspaceScopePayload): void {
|
||||||
this.scope = scope
|
this.scope = scope
|
||||||
this.render()
|
this.render()
|
||||||
@@ -175,11 +183,14 @@ export class RuntimeControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async load(force = false): Promise<void> {
|
private async load(force = false): Promise<void> {
|
||||||
if (force) this.controlsLoaded = false
|
if (force && Date.now() - this.controlsLoadedAt >= CONTROLS_CACHE_MS) {
|
||||||
|
this.controlsLoaded = false
|
||||||
|
}
|
||||||
if (this.controlsLoaded) return
|
if (this.controlsLoaded) return
|
||||||
if (this.controlsPromise) return this.controlsPromise
|
if (this.controlsPromise) return this.controlsPromise
|
||||||
if (!this.options.apiUrl || !this.options.apiToken) {
|
if (!this.options.apiUrl || !this.options.apiToken) {
|
||||||
this.controlsLoaded = true
|
this.controlsLoaded = true
|
||||||
|
this.controlsLoadedAt = Date.now()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.controlsPromise = (async () => {
|
this.controlsPromise = (async () => {
|
||||||
@@ -197,6 +208,7 @@ export class RuntimeControls {
|
|||||||
this.modelPresets = [...presets.values()]
|
this.modelPresets = [...presets.values()]
|
||||||
this.canUseFullAccess = controls.canUseFullAccess
|
this.canUseFullAccess = controls.canUseFullAccess
|
||||||
this.controlsLoaded = true
|
this.controlsLoaded = true
|
||||||
|
this.controlsLoadedAt = Date.now()
|
||||||
})().finally(() => { this.controlsPromise = null })
|
})().finally(() => { this.controlsPromise = null })
|
||||||
return this.controlsPromise
|
return this.controlsPromise
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-2
@@ -45,6 +45,11 @@ interface Activity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVITY_PREVIEW_LINES = 6
|
const ACTIVITY_PREVIEW_LINES = 6
|
||||||
|
// OpenTUI renders at 30 FPS. Re-parsing the entire Markdown buffer for every
|
||||||
|
// provider token turns long answers into quadratic work without producing any
|
||||||
|
// additional visible frames. Paint the first token immediately, then coalesce
|
||||||
|
// subsequent deltas to the renderer cadence.
|
||||||
|
const STREAM_FLUSH_MS = 32
|
||||||
|
|
||||||
/** Projects gateway events into retained, reflowable conversation cells. */
|
/** Projects gateway events into retained, reflowable conversation cells. */
|
||||||
export class Transcript {
|
export class Transcript {
|
||||||
@@ -64,6 +69,8 @@ export class Transcript {
|
|||||||
private nextId = 0
|
private nextId = 0
|
||||||
private navigation: TranscriptNavigation = { awayFromBottom: false, unseenOutput: false }
|
private navigation: TranscriptNavigation = { awayFromBottom: false, unseenOutput: false }
|
||||||
private navigationTimer: ReturnType<typeof setTimeout> | null = null
|
private navigationTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private pendingStream = ""
|
||||||
|
private streamTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly renderer: CliRenderer,
|
private readonly renderer: CliRenderer,
|
||||||
@@ -142,6 +149,8 @@ export class Transcript {
|
|||||||
reset(header: TranscriptHeader): void {
|
reset(header: TranscriptHeader): void {
|
||||||
if (this.navigationTimer) clearTimeout(this.navigationTimer)
|
if (this.navigationTimer) clearTimeout(this.navigationTimer)
|
||||||
this.navigationTimer = null
|
this.navigationTimer = null
|
||||||
|
this.clearStreamTimer()
|
||||||
|
this.pendingStream = ""
|
||||||
for (const child of [...this.root.getChildren()]) {
|
for (const child of [...this.root.getChildren()]) {
|
||||||
this.root.remove(child)
|
this.root.remove(child)
|
||||||
child.destroyRecursively()
|
child.destroyRecursively()
|
||||||
@@ -237,11 +246,18 @@ export class Transcript {
|
|||||||
const row = this.writeAssistant(markdown)
|
const row = this.writeAssistant(markdown)
|
||||||
this.live = { row, markdown, content: "" }
|
this.live = { row, markdown, content: "" }
|
||||||
}
|
}
|
||||||
this.live.content += delta
|
if (!this.live.content && !this.pendingStream) {
|
||||||
this.live.markdown.content = this.live.content
|
this.live.content = delta
|
||||||
|
this.live.markdown.content = delta
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.pendingStream += delta
|
||||||
|
if (this.streamTimer) return
|
||||||
|
this.streamTimer = setTimeout(() => this.flushStream(), STREAM_FLUSH_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
finishStream(fallback = ""): void {
|
finishStream(fallback = ""): void {
|
||||||
|
this.flushStream()
|
||||||
if (this.live) {
|
if (this.live) {
|
||||||
const content = fallback || this.live.content
|
const content = fallback || this.live.content
|
||||||
// Finalize the retained Markdown node in place. This preserves scroll
|
// Finalize the retained Markdown node in place. This preserves scroll
|
||||||
@@ -256,6 +272,8 @@ export class Transcript {
|
|||||||
|
|
||||||
reconcileStream(content: string): void {
|
reconcileStream(content: string): void {
|
||||||
if (!content || !this.live) return
|
if (!content || !this.live) return
|
||||||
|
this.clearStreamTimer()
|
||||||
|
this.pendingStream = ""
|
||||||
this.live.content = content
|
this.live.content = content
|
||||||
this.live.markdown.content = content
|
this.live.markdown.content = content
|
||||||
}
|
}
|
||||||
@@ -303,6 +321,8 @@ export class Transcript {
|
|||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
if (this.navigationTimer) clearTimeout(this.navigationTimer)
|
if (this.navigationTimer) clearTimeout(this.navigationTimer)
|
||||||
|
this.clearStreamTimer()
|
||||||
|
this.pendingStream = ""
|
||||||
this.live = null
|
this.live = null
|
||||||
this.activity = null
|
this.activity = null
|
||||||
this.frames.clear()
|
this.frames.clear()
|
||||||
@@ -310,6 +330,19 @@ export class Transcript {
|
|||||||
this.theme.syntax.destroy()
|
this.theme.syntax.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private flushStream(): void {
|
||||||
|
this.clearStreamTimer()
|
||||||
|
if (!this.live || !this.pendingStream) return
|
||||||
|
this.live.content += this.pendingStream
|
||||||
|
this.pendingStream = ""
|
||||||
|
this.live.markdown.content = this.live.content
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearStreamTimer(): void {
|
||||||
|
if (this.streamTimer) clearTimeout(this.streamTimer)
|
||||||
|
this.streamTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
private noteOutput(): void {
|
private noteOutput(): void {
|
||||||
this.updateNavigation(true)
|
this.updateNavigation(true)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user