feat(tui): unify session history and context

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent b3c3a82075
commit 6301c0ab57
24 changed files with 880 additions and 84 deletions
+111 -5
View File
@@ -16,6 +16,7 @@ import {
import {
NanobotClient,
fetchHistory,
fetchSessionContext,
fetchSessions,
fetchSlashCommands,
type ConnectionStatus,
@@ -30,7 +31,9 @@ import {
type TuiCommand,
} from "./command-menu"
import { SessionMenu } from "./session-menu"
import { ContextPanel, type ContextPanelTheme } from "./context-panel"
import { Transcript, type TranscriptTheme } from "./transcript"
import { rememberChat } from "./session-state"
interface AppOptions {
wsUrl: string
@@ -42,6 +45,7 @@ interface AppOptions {
version: string
access: string
theme: "auto" | ThemeMode
statePath?: string
}
interface ChatClient {
@@ -109,6 +113,12 @@ const LOCAL_COMMANDS: TuiCommand[] = [
description: "Keep this conversation and start another",
action: "new-chat",
},
{
command: "/context",
title: "Agent context",
description: "Explain what this session contributes to the next prompt",
action: "context",
},
]
function syntaxStyle(palette: Palette): SyntaxStyle {
@@ -157,6 +167,15 @@ function commandMenuTheme(palette: Palette): CommandMenuTheme {
}
}
function contextPanelTheme(palette: Palette): ContextPanelTheme {
return {
text: palette.text,
muted: palette.muted,
border: palette.border,
accent: palette.accent,
}
}
function formatElapsed(milliseconds: number): string {
const seconds = Math.max(0, Math.floor(milliseconds / 1000))
if (seconds < 60) return `${seconds}s`
@@ -187,6 +206,7 @@ export class NanobotTui {
private readonly transcript: Transcript
private readonly commandMenu: CommandMenu
private readonly sessionMenu: SessionMenu
private readonly contextPanel: ContextPanel
private readonly client: ChatClient
private readonly shell: BoxRenderable
private readonly title: TextRenderable
@@ -203,6 +223,9 @@ export class NanobotTui {
private finalMessage = ""
private turnHadAnswer = false
private historyLoaded = false
private historyBeforeCursor: string | null = null
private historyHasMore = false
private historyLoadingOlder = false
private attachedOnce = false
private pendingEvents: InboundEvent[] | null = null
private hydrationId = 0
@@ -234,6 +257,7 @@ export class NanobotTui {
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
this.commandMenu.setCommands([], LOCAL_COMMANDS)
this.sessionMenu = new SessionMenu(renderer, commandMenuTheme(this.palette))
this.contextPanel = new ContextPanel(renderer, contextPanelTheme(this.palette))
this.client = client || new NanobotClient({
url: options.wsUrl,
chatId: options.chatId,
@@ -292,6 +316,7 @@ export class NanobotTui {
{ name: "return", meta: true, action: "newline" },
],
onContentChange: () => {
if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide()
this.syncComposerPlaceholder()
if (this.sessionMenu.visible) this.syncSessionMenu()
else this.syncCommandMenu()
@@ -335,6 +360,7 @@ export class NanobotTui {
this.shell.add(this.transcript.root)
this.shell.add(this.commandMenu.root)
this.shell.add(this.sessionMenu.root)
this.shell.add(this.contextPanel.root)
this.shell.add(this.title)
this.shell.add(this.composerFrame)
this.shell.add(statusRow)
@@ -425,6 +451,7 @@ export class NanobotTui {
const command = this.commandMenu.resolve(content)
if (command?.source === "tui") {
if (command.command.action === "sessions") void this.openSessions()
else if (command.command.action === "context") void this.openContext()
else this.startNewChat()
return
}
@@ -464,6 +491,7 @@ export class NanobotTui {
accept(event: InboundEvent): void {
if (event.event === "attached") {
void rememberChat(this.options.statePath, event.chat_id)
this.commandTurns.clear()
const restoring = this.attachedOnce
this.attachedOnce = true
@@ -577,6 +605,10 @@ export class NanobotTui {
private async prepareChat(chatId: string, restoring: boolean, hydrationId: number): Promise<void> {
try {
if (restoring) {
this.contextPanel.hide()
this.historyBeforeCursor = null
this.historyHasMore = false
this.historyLoadingOlder = false
this.transcript.reset({
model: this.modelName,
workspace: this.options.workspace,
@@ -588,9 +620,8 @@ export class NanobotTui {
this.historyLoaded = true
const history = await fetchHistory(this.options.apiUrl, this.options.apiToken, chatId)
if (hydrationId !== this.hydrationId) return
if (history.truncated) {
this.transcript.notice("Earlier messages omitted · open WebUI to load the full history")
}
this.historyBeforeCursor = history.beforeCursor
this.historyHasMore = history.hasMoreBefore
this.transcript.history(history.messages)
}
} catch (error) {
@@ -599,7 +630,9 @@ export class NanobotTui {
} finally {
if (hydrationId !== this.hydrationId) return
this.ready = true
if (!this.activeTurn) this.status.content = "Ready"
if (!this.activeTurn) {
this.status.content = this.historyHasMore ? "Ready · PageUp for earlier history" : "Ready"
}
}
}
@@ -658,6 +691,12 @@ export class NanobotTui {
}
private handleKey = (key: KeyEvent): void => {
if (this.contextPanel.visible && key.name === "escape") {
this.contextPanel.hide()
this.updateMeta()
key.preventDefault()
return
}
if (this.sessionLoading && key.name === "escape") {
this.closeSessions()
this.status.content = "Ready"
@@ -748,7 +787,10 @@ export class NanobotTui {
}
if (key.name === "pageup" || key.name === "pagedown") {
key.preventDefault()
this.transcript.scrollByPage(key.name === "pageup" ? -1 : 1)
const pageUp = key.name === "pageup"
const wasAtTop = this.transcript.atTop
this.transcript.scrollByPage(pageUp ? -1 : 1)
if (pageUp && (wasAtTop || this.transcript.atTop)) void this.loadOlderHistory()
return
}
if (key.ctrl && (key.name === "home" || key.name === "end")) {
@@ -791,6 +833,7 @@ export class NanobotTui {
this.transcript.setTheme(transcriptTheme(this.palette))
this.commandMenu.setTheme(commandMenuTheme(this.palette))
this.sessionMenu.setTheme(commandMenuTheme(this.palette))
this.contextPanel.setTheme(contextPanelTheme(this.palette))
this.composerFrame.borderColor = this.palette.border
this.composer.textColor = this.palette.text
this.composer.focusedTextColor = this.palette.text
@@ -802,6 +845,7 @@ export class NanobotTui {
private handleResize = (): void => {
this.resizeComposer()
this.contextPanel.resize(this.renderer.height)
this.title.visible = this.renderer.height >= 14
this.updateMeta()
}
@@ -823,6 +867,10 @@ export class NanobotTui {
: "enter open · esc close"
return
}
if (this.contextPanel.visible) {
this.meta.content = "esc close · pgup/pgdn scroll"
return
}
this.meta.content = this.renderer.width >= 112
? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop"
: this.renderer.width >= 72
@@ -891,6 +939,7 @@ export class NanobotTui {
return
}
this.commandMenu.hide()
this.contextPanel.hide()
this.composer.setText("")
this.sessionLoading = true
const loadId = ++this.sessionLoadId
@@ -947,6 +996,7 @@ export class NanobotTui {
}
this.commandMenu.hide()
this.sessionMenu.hide()
this.contextPanel.hide()
this.composer.setText("")
try {
this.ready = false
@@ -1019,6 +1069,62 @@ export class NanobotTui {
this.updateMeta()
}
private async openContext(): Promise<void> {
this.commandMenu.hide()
this.sessionMenu.hide()
this.composer.setText("")
this.status.content = "Reading agent context…"
try {
const context = await fetchSessionContext(
this.options.apiUrl,
this.options.apiToken,
this.client.activeChatId,
)
if (!context) {
this.status.content = "Context unavailable · new session or older gateway"
return
}
this.contextPanel.show(context)
this.status.content = "Context snapshot"
this.updateMeta()
} catch (error) {
this.status.content = error instanceof Error ? error.message : String(error)
}
}
private async loadOlderHistory(): Promise<void> {
if (
this.historyLoadingOlder
|| !this.historyHasMore
|| !this.historyBeforeCursor
|| !this.client.activeChatId
) return
const hydrationId = this.hydrationId
const chatId = this.client.activeChatId
this.historyLoadingOlder = true
this.status.content = "Loading earlier messages…"
try {
const history = await fetchHistory(
this.options.apiUrl,
this.options.apiToken,
chatId,
this.historyBeforeCursor,
)
if (hydrationId !== this.hydrationId || chatId !== this.client.activeChatId) return
await this.transcript.prependHistory(history.messages)
this.historyBeforeCursor = history.beforeCursor
this.historyHasMore = history.hasMoreBefore
this.status.content = history.hasMoreBefore
? `${history.messages.length} earlier messages · PageUp for more`
: "Start of session"
} catch (error) {
if (hydrationId !== this.hydrationId) return
this.status.content = error instanceof Error ? error.message : String(error)
} finally {
if (hydrationId === this.hydrationId) this.historyLoadingOlder = false
}
}
private async copySelection(text: string): Promise<void> {
if (!text) return
try {