feat(tui): add agent interaction workflows

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent e77eed76c9
commit 4391bbf4da
28 changed files with 1275 additions and 87 deletions
+11
View File
@@ -20,12 +20,21 @@ composer; nanobot sends the original text unchanged.
Type `/` to discover slash commands published by the connected gateway. Use the arrow keys
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` queues a follow-up;
press `Enter` again on an empty composer to steer the current turn with the newest queued prompt.
Unsent prompts return to the composer if the turn stops or fails.
Use `/sessions` to search and switch persisted conversations without leaving the terminal.
`/new-chat` preserves the current conversation and starts another one; nanobot's existing `/new`
command keeps its cross-channel behavior and resets the current chat. The next launch returns to
the last session unless `--session` selects another one. When earlier transcript pages exist,
press `PageUp` at the top to load them in place.
`/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.
`/context` explains the session-owned material available for the next agent turn: the compacted
summary, replayable raw suffix, and an estimated token count. It deliberately does not expose
private reasoning and does not pretend to be the complete model prompt; workspace instructions,
@@ -34,3 +43,5 @@ memory, and skills are assembled separately by the Python runtime.
`/diff` opens the latest turn's file changes in a full-screen unified diff. Use `Left`/`Right`
to switch edits, `PageUp`/`PageDown` or `Home`/`End` to navigate, and `Esc` to return to chat.
The gateway remains the source of the patch; the TUI never rereads workspace files to rebuild it.
The footer reports provider token/cache usage when available, and tool activity uses compact,
tool-specific summaries while retaining the full event history behind `Ctrl+O`.
+86 -7
View File
@@ -7,7 +7,7 @@ import {
} from "@opentui/core/testing"
import { NanobotTui, type AppOptions } from "./app"
import type { SlashCommand } from "./protocol"
import type { MessageOptions, SlashCommand } from "./protocol"
const options: AppOptions = {
wsUrl: "ws://localhost.invalid/ws",
@@ -55,13 +55,20 @@ async function waitUntil(predicate: () => boolean, timeout = 1_000): Promise<voi
if (!predicate()) throw new Error(`condition was not met within ${timeout}ms`)
}
function client(sent: string[] = [], attached: string[] = [], newChats: string[] = []) {
function client(
sent: string[] = [],
attached: string[] = [],
newChats: string[] = [],
sentOptions: MessageOptions[] = [],
forks: Array<{ source: string; before: number; title?: string }> = [],
) {
return {
activeChatId: "chat",
connect() {},
close() {},
send(content: string) {
send(content: string, options: MessageOptions = {}) {
sent.push(content)
sentOptions.push(options)
return "turn"
},
attach(chatId: string) {
@@ -70,6 +77,9 @@ function client(sent: string[] = [], attached: string[] = [], newChats: string[]
newChat() {
newChats.push("new")
},
forkChat(source: string, before: number, title?: string) {
forks.push({ source, before, ...(title ? { title } : {}) })
},
}
}
@@ -135,8 +145,8 @@ describe("NanobotTui layout", () => {
expect(occurrences(frame, "First **answer**.")).toBe(1)
expect(occurrences(frame, "Second answer.")).toBe(1)
expect(frame).toContain("✓ read_file")
expect(frame).not.toContain(" read_file")
expect(frame).toContain("✓ Read config.json")
expect(frame).not.toContain(" Read")
expect(frame).not.toContain("private chain of thought")
expect(frame).toContain("Ready · 1.2s")
})
@@ -204,6 +214,68 @@ describe("NanobotTui layout", () => {
expect(ui.composer.plainText).toBe("")
})
test("queues follow-ups and promotes the armed prompt to steering", async () => {
const sent: string[] = []
const sentOptions: MessageOptions[] = []
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
const app = NanobotTui.mount(
setup.renderer,
options,
client(sent, [], [], sentOptions),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
app.accept({ event: "attached", chat_id: "chat" })
const ui = app as unknown as {
ready: boolean
composer: TextareaRenderable
mentionCandidates: Array<Record<string, unknown>>
}
await waitUntil(() => ui.ready)
ui.mentionCandidates = [{
kind: "cli",
name: "github",
displayName: "GitHub",
description: "CLI",
}]
ui.composer.setText("first")
ui.composer.submit()
await waitUntil(() => sent.length === 1)
ui.composer.setText("ask @github next")
ui.composer.submit()
await waitUntil(() => ui.composer.plainText === "")
expect(sent).toEqual(["first"])
ui.composer.submit()
await waitUntil(() => sent.length === 2)
expect(sentOptions[1]).toEqual({
cliApps: [{ name: "github" }],
mcpPresets: [],
sessionMentions: [],
})
ui.composer.setText("after this turn")
ui.composer.submit()
await waitUntil(() => ui.composer.plainText === "")
app.accept({
event: "error",
chat_id: "chat",
turn_id: "failed-steering",
reason: "steering rejected",
})
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBeTrue()
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => ui.ready)
app.accept({ event: "goal_status", chat_id: "chat", status: "running", turn_id: "turn" })
app.accept({ event: "turn_end", chat_id: "chat", turn_id: "turn" })
await waitUntil(() => sent.length === 3)
expect(sent[2]).toBe("after this turn")
app.accept({ event: "goal_status", chat_id: "chat", status: "idle", turn_id: "prior" })
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBeTrue()
})
test("recalls submitted prompts without stealing multiline cursor movement", async () => {
const sent: string[] = []
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
@@ -991,11 +1063,18 @@ describe("NanobotTui layout", () => {
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
const app = mount(setup)
app.accept({ event: "attached", chat_id: "chat" })
app.accept({ event: "turn_end", chat_id: "chat", latency_ms: 1700 })
app.accept({
event: "turn_end",
chat_id: "chat",
latency_ms: 1700,
usage: { prompt_tokens: 1200, completion_tokens: 80, cached_tokens: 900 },
context_window_tokens: 128_000,
})
await setup.flush()
const footer = setup.captureCharFrame().split("\n").find((line) => line.includes("Ready · 1.7s")) || ""
expect(footer).toContain("Ready · 1.7s")
expect(footer).toContain("↑1.2k ↓80")
expect(footer).toContain("enter send")
expect(footer).not.toContain("1.7senter")
@@ -1205,7 +1284,7 @@ describe("NanobotTui layout", () => {
expect(frame).toMatch(/Working\s+0s/u)
expect(frame).not.toMatch(/[]/u)
expect(frame).toContain(" exec")
expect(frame).toContain(" Command pwd")
app.accept({ event: "turn_end", chat_id: "chat" })
})
+342 -21
View File
@@ -21,6 +21,7 @@ import {
import {
NanobotClient,
fetchHistory,
fetchMentionCandidates,
fetchSessionContext,
fetchSessions,
fetchSlashCommands,
@@ -28,8 +29,11 @@ import {
type FileEditEvent,
type HistoryMessage,
type InboundEvent,
type MentionCandidate,
type MessageOptions,
type SlashCommand,
type SessionSummary,
type TokenUsage,
} from "./protocol"
import {
CommandMenu,
@@ -53,6 +57,15 @@ import {
} from "./transcript"
import { rememberChat } from "./session-state"
import { ComposerDraft } from "./composer-draft"
import { BranchMenu, branchPoints } from "./branch-menu"
import {
MentionMenu,
insertMention,
mentionOptions,
mentionQuery,
type MentionQuery,
} from "./mention-menu"
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
interface AppOptions {
wsUrl: string
@@ -72,9 +85,10 @@ interface ChatClient {
readonly activeChatId: string
connect(): void
close(): void
send(content: string): string
send(content: string, options?: MessageOptions): string
attach(chatId: string): void
newChat(): void
forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void
}
interface Palette {
@@ -156,6 +170,12 @@ const LOCAL_COMMANDS: TuiCommand[] = [
description: "Inspect file changes from the latest turn",
action: "diff",
},
{
command: "/branch",
title: "Branch from reply",
description: "Continue from an earlier completed reply",
action: "branch",
},
]
function syntaxStyle(palette: Palette): SyntaxStyle {
@@ -265,6 +285,22 @@ function formatElapsed(milliseconds: number): string {
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`
}
function usageStatus(usage: TokenUsage | null): string {
if (!usage) return ""
const prompt = usage.prompt_tokens
const completion = usage.completion_tokens
const tokens = typeof prompt === "number" || typeof completion === "number"
? `${formatTokenCount(prompt || 0)}${formatTokenCount(completion || 0)}`
: typeof usage.total_tokens === "number" ? `${formatTokenCount(usage.total_tokens)} tok` : ""
const cached = typeof usage.cached_tokens === "number" && usage.cached_tokens > 0
? `${formatTokenCount(usage.cached_tokens)} cached`
: ""
const cost = typeof usage.cost_usd === "number" && usage.cost_usd > 0
? `$${usage.cost_usd < 0.01 ? usage.cost_usd.toFixed(4) : usage.cost_usd.toFixed(2)}`
: ""
return [tokens, cached, cost].filter(Boolean).join(" · ")
}
async function copyWithSystemClipboard(text: string): Promise<void> {
const commands = process.platform === "darwin"
? [["pbcopy"]]
@@ -289,6 +325,8 @@ export class NanobotTui {
private readonly transcript: Transcript
private readonly commandMenu: CommandMenu
private readonly sessionMenu: SessionMenu
private readonly mentionMenu: MentionMenu
private readonly branchMenu: BranchMenu
private readonly contextPanel: ContextPanel
private readonly diffViewer: DiffViewer
private readonly client: ChatClient
@@ -301,10 +339,12 @@ export class NanobotTui {
private readonly status: TextRenderable
private readonly meta: TextRenderable
private readonly draft = new ComposerDraft()
private readonly promptQueue = new PromptQueue()
private palette: Palette
private activeThemeMode: ThemeMode
private backgroundKnown: boolean
private activeTurn = false
private activeTurnId: string | null = null
private activeLabel = "Thinking"
private activeStartedAt = 0
private lastProgress = ""
@@ -333,6 +373,11 @@ export class NanobotTui {
private sessionTitle = ""
private sessionMetadataId = 0
private contextTokens: number | null = null
private contextWindowTokens: number | null = null
private lastUsage: TokenUsage | null = null
private readyDetail = ""
private mentionCandidates: MentionCandidate[] = []
private activeMentionQuery: MentionQuery | null = null
private transcriptNavigation: TranscriptNavigation = {
awayFromBottom: false,
unseenOutput: false,
@@ -369,6 +414,8 @@ 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.mentionMenu = new MentionMenu(renderer, commandMenuTheme(this.palette))
this.branchMenu = new BranchMenu(renderer, commandMenuTheme(this.palette))
this.contextPanel = new ContextPanel(renderer, contextPanelTheme(this.palette))
this.diffViewer = new DiffViewer(
renderer,
@@ -455,7 +502,8 @@ export class NanobotTui {
if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide()
this.syncComposerPlaceholder()
if (this.sessionMenu.visible) this.syncSessionMenu()
else this.syncCommandMenu()
else if (this.branchMenu.visible) this.syncBranchMenu()
else this.syncComposerMenus()
this.resizeComposer()
},
// IMEs may commit their final composed glyph after Enter. Matching the
@@ -497,6 +545,8 @@ 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.mentionMenu.root)
this.shell.add(this.branchMenu.root)
this.shell.add(this.contextPanel.root)
this.shell.add(this.title)
this.shell.add(this.composerFrame)
@@ -547,6 +597,7 @@ export class NanobotTui {
}
this.client.connect()
void this.loadCommands()
void this.loadMentions()
this.renderer.start()
}
@@ -579,7 +630,23 @@ export class NanobotTui {
if (session) this.switchSession(session)
return
}
if (!visibleContent) return
if (this.branchMenu.visible) {
const point = this.branchMenu.choose()
if (point) this.createBranch(point.beforeUserIndex, point.preview)
return
}
if (this.mentionMenu.visible && this.activeMentionQuery) {
const candidate = this.mentionMenu.choose()
if (candidate) this.chooseMention(candidate, this.activeMentionQuery)
return
}
if (!visibleContent) {
if (this.activeTurn) {
const steering = this.promptQueue.takeSteering()
if (steering) this.sendPrompt(steering, true)
}
return
}
const completion = this.commandMenu.completion(visibleContent)
if (completion) {
this.setComposer(completion)
@@ -592,6 +659,7 @@ export class NanobotTui {
if (command.command.action === "sessions") void this.openSessions()
else if (command.command.action === "context") void this.openContext()
else if (command.command.action === "diff") this.openDiff()
else if (command.command.action === "branch") void this.openBranch()
else this.startNewChat()
return
}
@@ -608,31 +676,53 @@ export class NanobotTui {
this.quit()
return
}
const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
if (this.activeTurn) {
this.status.content = "A turn is already running · Ctrl+C to stop"
this.promptQueue.enqueue(prompt)
this.clearComposer()
this.commandMenu.hide()
this.mentionMenu.hide()
this.recordPrompt(content)
this.renderActiveStatus()
this.updateMeta()
return
}
this.sendPrompt(prompt)
}
private sendPrompt(prompt: QueuedPrompt, steering = false): boolean {
let turnId: string
try {
this.client.send(content)
turnId = this.client.send(prompt.content, prompt.options)
} catch (error) {
this.status.content = error instanceof Error ? error.message : String(error)
return
return false
}
this.clearComposer()
this.commandMenu.hide()
this.recordPrompt(content)
this.transcript.user(content)
this.mentionMenu.hide()
this.recordPrompt(prompt.content)
this.transcript.user(prompt.content)
if (steering) {
this.status.content = `Steering current turn${this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""}`
this.updateMeta()
return true
}
this.activeTurnId = turnId
this.readyDetail = ""
this.finalMessage = ""
this.turnHadAnswer = false
this.lastProgress = ""
this.activeLabel = "Thinking"
this.currentFileEdits = []
this.setActive(true)
return true
}
accept(event: InboundEvent): void {
if (event.event === "attached") {
void rememberChat(this.options.statePath, event.chat_id)
if (event.usage) this.lastUsage = event.usage
if (event.model_preset !== undefined) {
this.applyModelPreset(event.model_preset)
this.updateTitle()
@@ -641,7 +731,10 @@ export class NanobotTui {
this.modelCommandTurns.clear()
const restoring = this.attachedOnce
this.attachedOnce = true
if (restoring) this.setActive(false)
if (restoring) {
this.activeTurnId = null
this.setActive(false)
}
const queuesEvents = restoring || (!this.historyLoaded && Boolean(this.options.chatId))
if (queuesEvents) {
this.ready = false
@@ -716,6 +809,7 @@ export class NanobotTui {
}
return
case "turn_end":
if (event.turn_id && this.activeTurnId && event.turn_id !== this.activeTurnId) return
if (event.turn_id) {
this.commandTurns.delete(event.turn_id)
this.modelCommandTurns.delete(event.turn_id)
@@ -727,14 +821,24 @@ export class NanobotTui {
if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits)
this.finalMessage = ""
this.turnHadAnswer = false
this.setActive(false)
if (typeof event.latency_ms === "number") {
this.status.content = this.readyStatus(`${(event.latency_ms / 1000).toFixed(1)}s`)
this.activeTurnId = null
if (event.usage) this.lastUsage = event.usage
if (typeof event.context_window_tokens === "number") {
this.contextWindowTokens = event.context_window_tokens
}
this.updateTitle()
this.setActive(false)
this.readyDetail = typeof event.latency_ms === "number"
? `${(event.latency_ms / 1000).toFixed(1)}s`
: ""
this.status.content = this.readyStatus()
if (this.contextTokens !== null) void this.refreshContextEstimate(event.chat_id)
this.sendNextFollowUp()
return
case "goal_status":
if (event.turn_id && this.activeTurnId && event.turn_id !== this.activeTurnId) return
if (event.status === "running") {
if (event.turn_id) this.activeTurnId = event.turn_id
this.activeLabel = "Working"
this.setActive(true, typeof event.started_at === "number" ? event.started_at * 1000 : undefined)
} else {
@@ -744,6 +848,9 @@ export class NanobotTui {
case "goal_state":
return
case "turn_model_updated":
if (typeof event.context_window_tokens === "number") {
this.contextWindowTokens = event.context_window_tokens
}
this.setTurnModel(event.model_name, event.model_preset)
return
case "runtime_model_updated":
@@ -765,6 +872,10 @@ export class NanobotTui {
this.commandTurns.delete(event.turn_id)
this.modelCommandTurns.delete(event.turn_id)
}
if (event.turn_id && this.activeTurnId && event.turn_id !== this.activeTurnId) {
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
return
}
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
if (!commandLifecycle || commandLifecycle === "agent_turn") {
@@ -774,6 +885,7 @@ export class NanobotTui {
}
this.finalMessage = ""
this.turnHadAnswer = false
this.restoreQueuedPrompts()
this.setActive(false)
return
}
@@ -877,24 +989,41 @@ export class NanobotTui {
? ` · ${this.lastProgress.replace(/^\s*[·×]\s*/u, "")}`
: ""
const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""
this.status.content = shimmerStatus(
this.activeLabel,
` ${elapsed}${progress}${navigation}`,
` ${elapsed}${progress}${queued}${navigation}`,
this.shimmerFrame,
this.palette,
)
}
private readyStatus(detail = ""): string {
private readyStatus(detail = this.readyDetail): string {
if (this.transcriptNavigation.awayFromBottom) {
return this.transcriptNavigation.unseenOutput
? "New output · Ctrl+End latest"
: "History · Ctrl+End latest"
}
if (detail) return `Ready · ${detail}`
const usage = usageStatus(this.lastUsage)
const suffix = [detail, usage].filter(Boolean).join(" · ")
if (suffix) return `Ready · ${suffix}`
return this.historyHasMore ? "Ready · PageUp for earlier history" : "Ready"
}
private sendNextFollowUp(): void {
if (!this.ready || this.activeTurn || this.quitting) return
const prompt = this.promptQueue.takeFollowUp()
if (!prompt) return
this.sendPrompt(prompt)
}
private restoreQueuedPrompts(): void {
const queued = this.promptQueue.restore()
if (!queued.length) return
const current = this.draft.expand(this.composer.plainText).trim()
this.setComposer([current, ...queued.map((prompt) => prompt.content)].filter(Boolean).join("\n\n"))
}
private handleTranscriptNavigation(state: TranscriptNavigation): void {
this.transcriptNavigation = state
if (this.activeTurn) this.renderActiveStatus()
@@ -940,6 +1069,38 @@ export class NanobotTui {
return
}
}
if (this.branchMenu.visible) {
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
this.branchMenu.move(key.name === "up" ? -1 : 1)
key.preventDefault()
return
}
if (key.name === "escape") {
this.closeBranch()
key.preventDefault()
return
}
}
if (this.mentionMenu.visible) {
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
this.mentionMenu.move(key.name === "up" ? -1 : 1)
key.preventDefault()
return
}
if (!key.ctrl && !key.meta && key.name === "tab" && this.activeMentionQuery) {
const candidate = this.mentionMenu.choose()
if (candidate) this.chooseMention(candidate, this.activeMentionQuery)
key.preventDefault()
return
}
if (key.name === "escape") {
this.mentionMenu.hide()
this.activeMentionQuery = null
this.updateMeta()
key.preventDefault()
return
}
}
if (this.commandMenu.visible) {
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
this.commandMenu.move(key.name === "up" ? -1 : 1)
@@ -992,6 +1153,7 @@ export class NanobotTui {
return
}
if (this.activeTurn) {
this.restoreQueuedPrompts()
try {
this.client.send("/stop")
this.status.content = "Stopping…"
@@ -1061,6 +1223,8 @@ export class NanobotTui {
this.transcript.setTheme(transcriptTheme(this.palette, this.backgroundKnown))
this.commandMenu.setTheme(commandMenuTheme(this.palette))
this.sessionMenu.setTheme(commandMenuTheme(this.palette))
this.mentionMenu.setTheme(commandMenuTheme(this.palette))
this.branchMenu.setTheme(commandMenuTheme(this.palette))
this.contextPanel.setTheme(contextPanelTheme(this.palette))
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
this.updateComposerAppearance()
@@ -1083,10 +1247,22 @@ export class NanobotTui {
}
private updateMeta(): void {
if (this.mentionMenu.visible) {
this.meta.content = this.renderer.width >= 64
? "↑↓ choose · tab/enter insert · esc close"
: "enter insert · esc close"
return
}
if (this.activeTurn) {
this.meta.content = this.renderer.width >= 72 && this.transcriptNavigation.awayFromBottom
? "ctrl+end latest · ctrl+c stop"
: this.renderer.width >= 48 ? "ctrl+c stop" : ""
this.meta.content = this.renderer.width >= 96
? "enter queue · enter again steer · ctrl+c stop"
: this.renderer.width >= 64 ? "enter queue · ctrl+c stop" : ""
return
}
if (this.branchMenu.visible) {
this.meta.content = this.renderer.width >= 64
? "type to filter · ↑↓ choose · enter branch · esc close"
: "enter branch · esc close"
return
}
if (this.commandMenu.visible) {
@@ -1154,7 +1330,11 @@ export class NanobotTui {
const identity = this.sessionTitle.trim() || "nanobot"
this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38))
this.titleText.content = identity
const context = this.contextTokens === null ? "" : ` · ~${formatTokenCount(this.contextTokens)} ctx`
const context = this.contextTokens === null
? ""
: ` · ~${formatTokenCount(this.contextTokens)}${this.contextWindowTokens
? `/${formatTokenCount(this.contextWindowTokens)}`
: ""} ctx`
const runtime = this.modelPreset !== "default"
? [this.modelPreset, this.modelName].filter(Boolean).join(" · ")
: this.modelName
@@ -1186,7 +1366,9 @@ export class NanobotTui {
// prevents stale placeholder text in differential/embedded terminals.
const placeholder = this.composer.plainText
? null
: this.sessionMenu.visible ? "Search sessions" : COMPOSER_PLACEHOLDER
: this.sessionMenu.visible
? "Search sessions"
: this.branchMenu.visible ? "Search branch points" : COMPOSER_PLACEHOLDER
if (this.composer.placeholder !== placeholder) this.composer.placeholder = placeholder
}
@@ -1196,12 +1378,43 @@ export class NanobotTui {
this.updateMeta()
}
private syncComposerMenus(): void {
this.activeMentionQuery = mentionQuery(this.composer.plainText, this.composer.cursorOffset)
const candidates = this.availableMentions()
if (this.activeMentionQuery && candidates.length) {
this.commandMenu.hide()
const limit = this.renderer.height >= 20 ? 7 : 4
if (this.mentionMenu.visible) this.mentionMenu.update(this.activeMentionQuery.query, limit)
else this.mentionMenu.show(candidates, this.activeMentionQuery.query, limit)
this.updateMeta()
return
}
this.mentionMenu.hide()
this.syncCommandMenu()
}
private syncSessionMenu(): void {
const limit = this.renderer.height >= 20 ? 8 : 4
this.sessionMenu.update(this.composer.plainText, limit)
this.updateMeta()
}
private syncBranchMenu(): void {
const limit = this.renderer.height >= 20 ? 8 : 4
this.branchMenu.update(this.composer.plainText, limit)
this.updateMeta()
}
private chooseMention(candidate: MentionCandidate, query: MentionQuery): void {
const inserted = insertMention(this.composer.plainText, candidate, query)
this.composer.setText(inserted.value)
this.composer.cursorOffset = inserted.cursor
this.mentionMenu.hide()
this.activeMentionQuery = null
this.syncComposerPlaceholder()
this.updateMeta()
}
private setComposer(content: string): void {
this.draft.clear()
this.composer.setText(content)
@@ -1239,12 +1452,102 @@ export class NanobotTui {
this.syncCommandMenu()
}
private async loadMentions(): Promise<void> {
try {
this.mentionCandidates = await fetchMentionCandidates(
this.options.apiUrl,
this.options.apiToken,
)
if (this.activeMentionQuery) this.syncComposerMenus()
} catch {
// Mentions are additive; plain text input remains fully functional.
}
}
private availableMentions(): MentionCandidate[] {
const currentKey = this.client.activeChatId
? `websocket:${this.client.activeChatId}`
: ""
return this.mentionCandidates.filter((candidate) => (
candidate.session?.session_key !== currentKey
))
}
private async openBranch(): Promise<void> {
if (this.activeTurn) {
this.status.content = "Wait for the current turn or press Ctrl+C"
return
}
if (!this.ready) {
this.status.content = "Preparing chat…"
return
}
this.commandMenu.hide()
this.sessionMenu.hide()
this.contextPanel.hide()
this.clearComposer()
this.status.content = "Loading branch points…"
const chatId = this.client.activeChatId
try {
const history = await fetchHistory(
this.options.apiUrl,
this.options.apiToken,
chatId,
)
if (chatId !== this.client.activeChatId) return
const points = branchPoints(history.messages)
const limit = this.renderer.height >= 20 ? 8 : 4
this.branchMenu.open(points, limit)
this.syncComposerPlaceholder()
this.updateMeta()
this.status.content = points.length ? `${points.length} branch points` : "No completed replies"
} catch (error) {
this.status.content = error instanceof Error ? error.message : String(error)
}
}
private createBranch(beforeUserIndex: number, preview: string): void {
if (!this.ready || this.activeTurn) return
this.branchMenu.hide()
this.clearComposer()
try {
if (!this.client.forkChat) throw new Error("branching is unavailable")
this.ready = false
this.promptQueue.clear()
this.sessionMetadataId += 1
this.sessionTitle = `Fork · ${preview.slice(0, 48)}`
this.contextTokens = null
this.lastUsage = null
this.readyDetail = ""
this.updateTitle()
this.status.content = "Creating branch…"
this.client.forkChat(
this.client.activeChatId,
beforeUserIndex,
this.sessionTitle,
)
} catch (error) {
this.ready = true
this.status.content = error instanceof Error ? error.message : String(error)
}
}
private closeBranch(): void {
this.branchMenu.hide()
this.clearComposer()
this.syncComposerPlaceholder()
if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
this.updateMeta()
}
private async openSessions(): Promise<void> {
if (this.activeTurn) {
this.status.content = "Wait for the current turn or press Ctrl+C"
return
}
this.commandMenu.hide()
this.mentionMenu.hide()
this.branchMenu.hide()
this.contextPanel.hide()
this.clearComposer()
this.sessionLoading = true
@@ -1293,10 +1596,13 @@ export class NanobotTui {
this.closeSessions()
try {
this.ready = false
this.promptQueue.clear()
this.sessionMetadataId += 1
this.sessionTitle = sessionLabel(session)
this.applySessionModel(session)
this.contextTokens = null
this.lastUsage = null
this.readyDetail = ""
this.updateTitle()
this.status.content = "Opening session…"
this.client.attach(session.chatId)
@@ -1316,16 +1622,21 @@ export class NanobotTui {
}
this.commandMenu.hide()
this.sessionMenu.hide()
this.mentionMenu.hide()
this.branchMenu.hide()
this.contextPanel.hide()
this.clearComposer()
try {
this.ready = false
this.promptQueue.clear()
this.sessionMetadataId += 1
this.sessionTitle = "New chat"
this.sessionModelPreset = null
this.modelName = this.defaultModelName
this.modelPreset = this.defaultModelPreset
this.contextTokens = null
this.lastUsage = null
this.readyDetail = ""
this.updateTitle()
this.status.content = "Starting a new chat…"
this.client.newChat()
@@ -1361,6 +1672,7 @@ export class NanobotTui {
this.recordPrompt(content)
if (lifecycle === "agent_turn") {
this.activeTurnId = turnId
this.finalMessage = ""
this.turnHadAnswer = false
this.lastProgress = ""
@@ -1368,6 +1680,7 @@ export class NanobotTui {
this.currentFileEdits = []
this.setActive(true)
} else if (lifecycle === "finalize_active_turn") {
this.activeTurnId = null
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
this.transcript.finishActivity()
this.finalMessage = ""
@@ -1375,6 +1688,7 @@ export class NanobotTui {
this.setActive(false)
this.status.content = "Resetting chat…"
} else if (lifecycle === "stop_active_turn") {
this.activeTurnId = null
this.setActive(false)
this.status.content = "Stopping…"
} else if (!this.activeTurn) {
@@ -1414,6 +1728,8 @@ export class NanobotTui {
private async openContext(): Promise<void> {
this.commandMenu.hide()
this.sessionMenu.hide()
this.mentionMenu.hide()
this.branchMenu.hide()
this.clearComposer()
this.status.content = "Reading agent context…"
try {
@@ -1427,6 +1743,7 @@ export class NanobotTui {
return
}
this.contextTokens = context.estimatedSessionTokens
this.lastUsage = context.lastUsage
this.updateTitle()
this.contextPanel.show(context)
this.status.content = "Context snapshot"
@@ -1462,9 +1779,11 @@ export class NanobotTui {
this.options.apiToken,
chatId,
)
if (!context || chatId !== this.client.activeChatId || this.contextTokens === null) return
if (!context || chatId !== this.client.activeChatId) return
this.contextTokens = context.estimatedSessionTokens
this.lastUsage = context.lastUsage || this.lastUsage
this.updateTitle()
if (!this.activeTurn) this.status.content = this.readyStatus()
} catch {
// Keep the last known estimate; it is intentionally informational.
}
@@ -1473,6 +1792,8 @@ export class NanobotTui {
private openDiff(): void {
this.commandMenu.hide()
this.sessionMenu.hide()
this.mentionMenu.hide()
this.branchMenu.hide()
this.contextPanel.hide()
this.clearComposer()
this.composer.blur()
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from "bun:test"
import { branchPoints } from "./branch-menu"
test("branch points preserve absolute user indices from paginated history", () => {
expect(branchPoints([
{ role: "user", content: "question" },
{ role: "assistant", content: " first\nreply ", forkIndex: 11 },
{ role: "activity", content: "read_file" },
{ role: "assistant", content: "second", forkIndex: 12 },
])).toEqual([
{ beforeUserIndex: 11, preview: "first reply" },
{ beforeUserIndex: 12, preview: "second" },
])
})
+40
View File
@@ -0,0 +1,40 @@
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
import type { HistoryMessage } from "./protocol"
export interface BranchPoint {
beforeUserIndex: number
preview: string
}
export function branchPoints(messages: HistoryMessage[]): BranchPoint[] {
return messages.flatMap((message) => (
message.role === "assistant" && typeof message.forkIndex === "number"
? [{ beforeUserIndex: message.forkIndex, preview: message.content.replace(/\s+/gu, " ").trim() }]
: []
))
}
export class BranchMenu {
readonly root: BoxRenderable
private readonly picker: PickerMenu<BranchPoint>
constructor(renderer: CliRenderer, theme: PickerMenuTheme) {
this.picker = new PickerMenu(renderer, theme, {
id: "nanobot-tui-branch-menu",
searchText: (point) => point.preview,
render: (point) => `After turn ${point.beforeUserIndex} ${point.preview}`,
emptyText: "No completed replies to branch from",
})
this.root = this.picker.root
}
get visible(): boolean { return this.picker.visible }
open(points: BranchPoint[], limit: number): void { this.picker.show(points, "", limit) }
update(query: string, limit: number): void { this.picker.update(query, limit) }
move(direction: -1 | 1): boolean { return this.picker.move(direction) }
choose(): BranchPoint | null { return this.picker.current() }
hide(): void { this.picker.hide() }
setTheme(theme: PickerMenuTheme): void { this.picker.setTheme(theme) }
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
export type CommandMenuTheme = PickerMenuTheme
export type TuiCommandAction = "sessions" | "new-chat" | "context" | "diff"
export type TuiCommandAction = "sessions" | "new-chat" | "context" | "diff" | "branch"
export interface TuiCommand {
command: string
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test"
import { insertMention, mentionOptions, mentionQuery } from "./mention-menu"
import type { MentionCandidate } from "./protocol"
const candidates: MentionCandidate[] = [
{ kind: "cli", name: "github", displayName: "GitHub", description: "CLI" },
{ kind: "mcp", name: "linear", displayName: "Linear", description: "MCP" },
{
kind: "session",
name: "release-plan",
displayName: "Release plan",
description: "Session",
session: { name: "release-plan", session_key: "websocket:release", title: "Release plan" },
},
]
describe("mention projection", () => {
test("finds and replaces only the mention under the cursor", () => {
const value = "ask @rel about this"
const query = mentionQuery(value, 8)
expect(query).toEqual({ query: "rel", start: 4, end: 8 })
expect(insertMention(value, candidates[2]!, query!).value).toBe("ask @release-plan about this")
})
test("keeps namespaced completion aliases separate from gateway capability ids", () => {
const options = mentionOptions("use @github-2", [{
kind: "mcp",
name: "github-2",
targetName: "github",
displayName: "GitHub MCP",
description: "MCP server",
}])
expect(options.mcpPresets).toEqual([{ name: "github" }])
})
test("maps visible mentions onto the gateway metadata lanes", () => {
expect(mentionOptions("Use @github with @linear and @release-plan", candidates)).toEqual({
cliApps: [{ name: "github" }],
mcpPresets: [{ name: "linear" }],
sessionMentions: [{
name: "release-plan",
session_key: "websocket:release",
title: "Release plan",
}],
})
})
})
+75
View File
@@ -0,0 +1,75 @@
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
import type { MentionCandidate, MessageOptions } from "./protocol"
export interface MentionQuery {
query: string
start: number
end: number
}
export function mentionQuery(value: string, cursor: number): MentionQuery | null {
const end = Math.min(Math.max(cursor, 0), value.length)
const match = /(?:^|[\s([{])@([\p{L}\p{N}_-]*)$/u.exec(value.slice(0, end))
if (!match) return null
const valueQuery = match[1] ?? ""
const start = end - valueQuery.length - 1
return { query: valueQuery.toLocaleLowerCase(), start, end }
}
export function insertMention(
value: string,
candidate: MentionCandidate,
query: MentionQuery,
): { value: string; cursor: number } {
const suffix = value.slice(query.end)
const tail = /^\s/u.test(suffix) ? "" : " "
const inserted = `@${candidate.name}${tail}`
return {
value: `${value.slice(0, query.start)}${inserted}${suffix}`,
cursor: query.start + inserted.length,
}
}
export function mentionOptions(value: string, candidates: MentionCandidate[]): MessageOptions {
const names = new Set(
[...value.matchAll(/(?:^|[\s([{])@([\p{L}\p{N}_-]+)/gu)]
.flatMap((match) => match[1] ? [match[1].toLocaleLowerCase()] : []),
)
const selected = candidates.filter((candidate) => names.has(candidate.name.toLocaleLowerCase()))
return {
cliApps: selected
.filter((item) => item.kind === "cli")
.map((item) => ({ name: item.targetName || item.name })),
mcpPresets: selected
.filter((item) => item.kind === "mcp")
.map((item) => ({ name: item.targetName || item.name })),
sessionMentions: selected.flatMap((item) => item.session ? [item.session] : []),
}
}
export class MentionMenu {
readonly root: BoxRenderable
private readonly picker: PickerMenu<MentionCandidate>
constructor(renderer: CliRenderer, theme: PickerMenuTheme) {
this.picker = new PickerMenu(renderer, theme, {
id: "nanobot-tui-mention-menu",
searchText: (item) => `${item.name} ${item.displayName} ${item.description}`,
render: (item) => `${item.displayName} @${item.name} · ${item.kind}`,
emptyText: "No matching sessions or tools",
})
this.root = this.picker.root
}
get visible(): boolean { return this.picker.visible }
show(items: MentionCandidate[], query: string, limit: number): void {
this.picker.show(items, query, limit)
}
update(query: string, limit: number): void { this.picker.update(query, limit) }
move(direction: -1 | 1): boolean { return this.picker.move(direction) }
choose(): MentionCandidate | null { return this.picker.current() }
hide(): void { this.picker.hide() }
setTheme(theme: PickerMenuTheme): void { this.picker.setTheme(theme) }
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import { PromptQueue } from "./prompt-queue"
const prompt = (content: string) => ({ content, options: {} })
describe("PromptQueue", () => {
test("promotes the newest armed prompt to steering", () => {
const queue = new PromptQueue()
queue.enqueue(prompt("next one"))
queue.enqueue(prompt("steer now"))
expect(queue.takeSteering()?.content).toBe("steer now")
expect(queue.takeSteering()).toBeNull()
expect(queue.takeFollowUp()?.content).toBe("next one")
})
test("keeps follow-ups FIFO and restores unsent drafts", () => {
const queue = new PromptQueue()
queue.enqueue(prompt("first"))
queue.enqueue(prompt("second"))
expect(queue.takeFollowUp()?.content).toBe("first")
expect(queue.restore().map(({ content }) => content)).toEqual(["second"])
expect(queue.length).toBe(0)
})
})
+45
View File
@@ -0,0 +1,45 @@
import type { MessageOptions } from "./protocol"
export interface QueuedPrompt {
content: string
options: MessageOptions
}
/** Owns the difference between steering the active turn and starting the next one. */
export class PromptQueue {
private prompts: QueuedPrompt[] = []
private armed = false
get length(): number {
return this.prompts.length
}
enqueue(prompt: QueuedPrompt): void {
this.prompts.push(prompt)
this.armed = true
}
/** A second Enter immediately promotes the newest queued prompt to steering. */
takeSteering(): QueuedPrompt | null {
if (!this.armed) return null
this.armed = false
return this.prompts.pop() ?? null
}
takeFollowUp(): QueuedPrompt | null {
this.armed = false
return this.prompts.shift() ?? null
}
restore(): QueuedPrompt[] {
const prompts = this.prompts
this.prompts = []
this.armed = false
return prompts
}
clear(): void {
this.prompts = []
this.armed = false
}
}
+77 -3
View File
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"
import {
NanobotClient,
fetchHistory,
fetchMentionCandidates,
fetchSessionContext,
fetchSessions,
fetchSlashCommands,
@@ -77,17 +78,31 @@ describe("gateway protocol", () => {
model_preset: "Deep Research",
}),
})
client.send("hello")
client.send("hello", {
cliApps: [{ name: "github" }],
sessionMentions: [{ name: "plan", session_key: "websocket:plan" }],
})
client.attach("other-chat")
client.newChat()
client.forkChat("terminal", 3, "Alternative")
const outbound = socket.sent.map((value) => JSON.parse(value) as Record<string, unknown>)
expect(outbound[0]).toEqual({ type: "attach", chat_id: "terminal" })
expect(outbound[1]?.type).toBe("message")
expect(outbound[1]?.chat_id).toBe("terminal")
expect(outbound[1]?.content).toBe("hello")
expect(outbound[1]?.cli_apps).toEqual([{ name: "github" }])
expect(outbound[1]?.session_mentions).toEqual([
{ name: "plan", session_key: "websocket:plan" },
])
expect(outbound[2]).toEqual({ type: "attach", chat_id: "other-chat" })
expect(outbound[3]).toEqual({ type: "new_chat" })
expect(outbound[4]).toEqual({
type: "fork_chat",
source_chat_id: "terminal",
before_user_index: 3,
title: "Alternative",
})
expect(events.map((event) => event.event)).toEqual(["ready", "attached"])
expect(events[1]).toEqual({
event: "attached",
@@ -268,7 +283,7 @@ describe("gateway protocol", () => {
toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }],
},
{ role: "assistant", kind: "reasoning", content: "private thought" },
{ role: "assistant", content: "hi" },
{ role: "assistant", content: "hi", forkIndex: 1 },
],
page: { has_more_before: true, before_cursor: "older-1" },
})))
@@ -284,10 +299,11 @@ describe("gateway protocol", () => {
content: "read_file",
toolEvents: [{ phase: "end", call_id: "read-1", name: "read_file" }],
},
{ role: "assistant", content: "hi" },
{ role: "assistant", content: "hi", forkIndex: 1 },
],
hasMoreBefore: true,
beforeCursor: "older-1",
userMessageOffset: 0,
})
expect(requested).toContain("before=newer-page")
} finally {
@@ -318,6 +334,7 @@ describe("gateway protocol", () => {
estimatedSessionTokens: 2176,
archivedSummary: "Older work was compacted.",
archivedSummaryAt: "2026-08-13T10:00:00Z",
lastUsage: null,
})
} finally {
globalThis.fetch = original
@@ -421,4 +438,61 @@ describe("gateway protocol", () => {
globalThis.fetch = original
}
})
test("combines installed tools and saved sessions in one mention namespace", async () => {
const original = globalThis.fetch
globalThis.fetch = ((input: string | URL | Request) => {
const url = String(input)
if (url.includes("cli-apps")) {
return Promise.resolve(new Response(JSON.stringify({
apps: [{ name: "github", display_name: "GitHub", description: "Repository tools", installed: true }],
})))
}
if (url.includes("mcp-presets")) {
return Promise.resolve(new Response(JSON.stringify({
presets: [{
name: "linear",
display_name: "Linear",
description: "Issue tracker",
installed: true,
configured: true,
}],
})))
}
if (url.includes("sidebar-state")) return Promise.resolve(new Response("{}"))
return Promise.resolve(new Response(JSON.stringify({
sessions: [{ key: "websocket:plan", title: "Release plan", preview: "Ship it" }],
})))
}) as typeof fetch
try {
expect(await fetchMentionCandidates("http://nanobot.test", "secret")).toEqual([
{
kind: "cli",
name: "github",
displayName: "GitHub",
description: "Repository tools",
},
{
kind: "mcp",
name: "linear",
displayName: "Linear",
description: "Issue tracker",
},
{
kind: "session",
name: "Release-plan",
displayName: "Release plan",
description: "Ship it",
session: {
name: "Release-plan",
session_key: "websocket:plan",
title: "Release plan",
},
},
])
} finally {
globalThis.fetch = original
}
})
})
+213 -17
View File
@@ -38,7 +38,12 @@ export interface FileDiff {
export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string }
| { event: "attached"; chat_id: string; model_preset?: string | null }
| {
event: "attached"
chat_id: string
model_preset?: string | null
usage?: TokenUsage
}
| { event: "message_accepted"; chat_id: string; turn_id: string }
| {
event: "message"
@@ -61,7 +66,14 @@ export type InboundEvent =
}
| { event: "reasoning_delta"; chat_id: string; text: string; turn_id?: string }
| { event: "reasoning_end"; chat_id: string; turn_id?: string }
| { event: "turn_end"; chat_id: string; latency_ms?: number; turn_id?: string }
| {
event: "turn_end"
chat_id: string
latency_ms?: number
turn_id?: string
usage?: TokenUsage
context_window_tokens?: number
}
| {
event: "goal_status"
chat_id: string
@@ -77,13 +89,24 @@ export type InboundEvent =
chat_id: string
model_name: string
model_preset?: string | null
context_window_tokens?: number
}
| { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string }
type OutboundEvent =
| { type: "new_chat" }
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string }
| { type: "message"; chat_id: string; content: string; turn_id: string; webui: true }
| {
type: "message"
chat_id: string
content: string
turn_id: string
webui: true
cli_apps?: Array<{ name: string }>
mcp_presets?: Array<{ name: string }>
session_mentions?: SessionMention[]
}
export interface ClientOptions {
url: string
@@ -98,12 +121,24 @@ export interface HistoryMessage {
content: string
toolEvents?: ToolProgressEvent[]
fileEdits?: FileEditEvent[]
forkIndex?: number
}
export interface HistorySnapshot {
messages: HistoryMessage[]
hasMoreBefore: boolean
beforeCursor: string | null
userMessageOffset: number
}
export interface TokenUsage {
prompt_tokens?: number
completion_tokens?: number
cached_tokens?: number
total_tokens?: number
provider_tokens?: number
estimated_tokens?: number
cost_usd?: number
}
export interface SessionContextSnapshot {
@@ -115,6 +150,28 @@ export interface SessionContextSnapshot {
estimatedSessionTokens: number
archivedSummary: string | null
archivedSummaryAt: string | null
lastUsage: TokenUsage | null
}
export interface SessionMention {
name: string
session_key: string
title?: string
}
export interface MentionCandidate {
kind: "session" | "cli" | "mcp"
name: string
targetName?: string
displayName: string
description: string
session?: SessionMention
}
export interface MessageOptions {
cliApps?: Array<{ name: string }>
mcpPresets?: Array<{ name: string }>
sessionMentions?: SessionMention[]
}
export interface SlashCommand {
@@ -213,6 +270,19 @@ function isFileDiff(value: unknown): value is FileDiff {
&& optional(value.text, "string")
}
function isTokenUsage(value: unknown): value is TokenUsage {
if (!isRecord(value)) return false
return [
"prompt_tokens",
"completion_tokens",
"cached_tokens",
"total_tokens",
"provider_tokens",
"estimated_tokens",
"cost_usd",
].every((key) => optional(value[key], "number"))
}
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
if (!isRecord(value)) return null
const record = value
@@ -240,9 +310,10 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
if (typeof record.chat_id !== "string") return null
if (
name === "attached"
&& record.model_preset !== undefined
&& record.model_preset !== null
&& typeof record.model_preset !== "string"
&& ((record.model_preset !== undefined
&& record.model_preset !== null
&& typeof record.model_preset !== "string")
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
) return null
if (["message", "delta", "reasoning_delta"].includes(name) && typeof record.text !== "string") {
return null
@@ -261,7 +332,12 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|| !optional(record.resuming, "boolean")
|| !optional(record.merge_next, "boolean"))
) return null
if (name === "turn_end" && !optional(record.latency_ms, "number")) return null
if (
name === "turn_end"
&& (!optional(record.latency_ms, "number")
|| !optional(record.context_window_tokens, "number")
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
) return null
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
if (name === "goal_state" && (!record.goal_state || typeof record.goal_state !== "object")) return null
if (name === "session_updated" && !optional(record.scope, "string")) return null
@@ -270,7 +346,8 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
&& (typeof record.model_name !== "string"
|| (record.model_preset !== undefined
&& record.model_preset !== null
&& typeof record.model_preset !== "string"))
&& typeof record.model_preset !== "string")
|| !optional(record.context_window_tokens, "number"))
) return null
return value as InboundEvent
}
@@ -282,7 +359,7 @@ export async function fetchHistory(
beforeCursor?: string | null,
): Promise<HistorySnapshot> {
if (!apiUrl || !apiToken) {
return { messages: [], hasMoreBefore: false, beforeCursor: null }
return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 }
}
const key = encodeURIComponent(`websocket:${chatId}`)
const params = new URLSearchParams({ limit: "120", direction: "latest" })
@@ -291,14 +368,18 @@ export async function fetchHistory(
headers: { Authorization: `Bearer ${apiToken}` },
})
if (response.status === 404) {
return { messages: [], hasMoreBefore: false, beforeCursor: null }
return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 }
}
if (!response.ok) throw new Error(`history request failed: HTTP ${response.status}`)
const payload = (await response.json()) as {
messages?: Array<Record<string, unknown>>
page?: { has_more_before?: boolean; before_cursor?: string }
page?: { has_more_before?: boolean; before_cursor?: string; user_message_offset?: number }
}
const messages: HistoryMessage[] = (payload.messages || []).flatMap((message) => {
let userIndex = typeof payload.page?.user_message_offset === "number"
? Math.max(0, payload.page.user_message_offset)
: 0
const messages: HistoryMessage[] = []
for (const message of payload.messages || []) {
const role = message.role
const content = message.content
if (role === "tool" && message.kind === "trace") {
@@ -312,7 +393,13 @@ export async function fetchHistory(
? message.fileEdits.filter(isFileEdit)
: undefined
const activity = traces.join("\n") || (typeof content === "string" ? content : "")
return [{ role: "activity", content: activity, toolEvents, fileEdits }]
messages.push({
role: "activity",
content: activity,
...(toolEvents?.length ? { toolEvents } : {}),
...(fileEdits?.length ? { fileEdits } : {}),
})
continue
}
if (
(role !== "user" && role !== "assistant")
@@ -320,16 +407,24 @@ export async function fetchHistory(
|| typeof content !== "string"
|| !content.trim()
) {
return []
continue
}
return [{ role: role as HistoryMessage["role"], content }]
})
if (role === "user") {
userIndex += 1
messages.push({ role: "user", content })
} else {
messages.push({ role: "assistant", content, forkIndex: userIndex })
}
}
return {
messages,
hasMoreBefore: payload.page?.has_more_before === true,
beforeCursor: typeof payload.page?.before_cursor === "string"
? payload.page.before_cursor
: null,
userMessageOffset: typeof payload.page?.user_message_offset === "number"
? Math.max(0, payload.page.user_message_offset)
: 0,
}
}
@@ -358,6 +453,7 @@ export async function fetchSessionContext(
archivedSummaryAt: typeof value.archived_summary_at === "string"
? value.archived_summary_at
: null,
lastUsage: isTokenUsage(value.last_usage) ? value.last_usage : null,
}
}
@@ -438,6 +534,92 @@ export async function fetchSessions(
})
}
function sessionMentionName(session: SessionSummary): string {
const label = (session.title || session.preview || "session")
.normalize("NFKC")
.replace(/\s+/gu, "-")
.replace(/[^\p{L}\p{N}_-]+/gu, "")
.replace(/-+/gu, "-")
.replace(/^-|-$/gu, "")
return Array.from(label || "session").slice(0, 40).join("")
}
/** Installed capabilities and saved chats share one mention namespace. */
export async function fetchMentionCandidates(
apiUrl: string,
apiToken: string,
): Promise<MentionCandidate[]> {
if (!apiUrl || !apiToken) return []
const headers = { Authorization: `Bearer ${apiToken}` }
const [sessions, appsResponse, mcpResponse] = await Promise.all([
fetchSessions(apiUrl, apiToken),
fetch(`${apiUrl}/api/settings/cli-apps?installed_only=1`, { headers }).catch(() => null),
fetch(`${apiUrl}/api/settings/mcp-presets`, { headers }).catch(() => null),
])
const used = new Set<string>()
const uniqueName = (raw: string) => {
const base = raw || "session"
let name = base
let suffix = 2
while (used.has(name.toLocaleLowerCase())) name = `${base}-${suffix++}`
used.add(name.toLocaleLowerCase())
return name
}
const candidates: MentionCandidate[] = []
if (appsResponse?.ok) {
const payload = await appsResponse.json() as { apps?: unknown[] }
for (const value of payload.apps || []) {
if (!isRecord(value) || value.installed !== true || typeof value.name !== "string") continue
const name = uniqueName(value.name)
candidates.push({
kind: "cli",
name,
...(name === value.name ? {} : { targetName: value.name }),
displayName: typeof value.display_name === "string" ? value.display_name : name,
description: typeof value.description === "string" ? value.description : "CLI app",
})
}
}
if (mcpResponse?.ok) {
const payload = await mcpResponse.json() as { presets?: unknown[] }
for (const value of payload.presets || []) {
if (
!isRecord(value)
|| value.installed !== true
|| value.configured !== true
|| typeof value.name !== "string"
) continue
const name = uniqueName(value.name)
candidates.push({
kind: "mcp",
name,
...(name === value.name ? {} : { targetName: value.name }),
displayName: typeof value.display_name === "string" ? value.display_name : name,
description: typeof value.description === "string" ? value.description : "MCP server",
})
}
}
for (const session of sessions) {
const name = uniqueName(sessionMentionName(session))
candidates.push({
kind: "session",
name,
displayName: sessionLabelForMention(session),
description: session.preview || "Saved session",
session: {
name,
session_key: `websocket:${session.chatId}`,
title: session.title || undefined,
},
})
}
return candidates
}
function sessionLabelForMention(session: SessionSummary): string {
return (session.title || session.preview || "Untitled chat").replace(/\s+/gu, " ").trim()
}
export class NanobotClient {
private socket: WebSocket | null = null
private chatId = ""
@@ -492,7 +674,7 @@ export class NanobotClient {
socket?.close()
}
send(content: string): string {
send(content: string, options: MessageOptions = {}): string {
if (!this.chatId) throw new Error("chat is not ready")
const turnId = crypto.randomUUID()
this.write({
@@ -501,6 +683,11 @@ export class NanobotClient {
content,
turn_id: turnId,
webui: true,
...(options.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options.sessionMentions?.length
? { session_mentions: options.sessionMentions }
: {}),
})
return turnId
}
@@ -514,6 +701,15 @@ export class NanobotClient {
this.write({ type: "new_chat" })
}
forkChat(sourceChatId: string, beforeUserIndex: number, title?: string): void {
this.write({
type: "fork_chat",
source_chat_id: sourceChatId,
before_user_index: beforeUserIndex,
...(title?.trim() ? { title: title.trim() } : {}),
})
}
private handleMessage(raw: string): void {
let value: unknown
try {
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test"
import { mergeToolEvent, renderToolEvent } from "./tool-renderers"
describe("tool renderers", () => {
test("retains start arguments when an end frame only carries output", () => {
const event = mergeToolEvent(
{ call_id: "exec-1", phase: "start", name: "exec", arguments: { cmd: "git status" } },
{ call_id: "exec-1", phase: "end", name: "exec", result: { output: "clean" } },
)
expect(renderToolEvent(event)).toBe(" ✓ Command git status")
})
test("uses stable task language for common file and web tools", () => {
expect(renderToolEvent({ phase: "end", name: "read_file", arguments: { path: "README.md" } }))
.toBe(" ✓ Read README.md")
expect(renderToolEvent({ phase: "start", name: "web_search", arguments: { query: "nanobot" } }))
.toBe(" Search web nanobot")
expect(renderToolEvent({ phase: "error", name: "web_fetch", error: "timeout" }))
.toBe(" × Fetch timeout")
})
})
+65
View File
@@ -0,0 +1,65 @@
import type { ToolProgressEvent } from "./protocol"
export function mergeToolEvent(
previous: ToolProgressEvent | undefined,
next: ToolProgressEvent,
): ToolProgressEvent {
if (!previous) return next
return {
...previous,
...next,
arguments: next.arguments ?? previous.arguments,
result: next.result ?? previous.result,
error: next.error ?? previous.error,
}
}
export function renderToolEvent(event: ToolProgressEvent): string {
const phase = event.phase || "start"
const marker = phase === "error" ? "×" : phase === "end" ? "✓" : ""
const name = (event.name || "tool").trim()
const args = record(event.arguments)
const result = record(event.result)
const detail = phase === "error" ? compact(event.error) : toolDetail(name, args, result)
return ` ${marker} ${toolLabel(name)}${detail ? ` ${detail}` : ""}`
}
function toolLabel(name: string): string {
if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) return "Command"
if (/^(?:read_file|read)$/u.test(name)) return "Read"
if (/^(?:write_file|write)$/u.test(name)) return "Write"
if (/^(?:edit_file|apply_patch|edit)$/u.test(name)) return "Edit"
if (name === "web_search") return "Search web"
if (name === "web_fetch") return "Fetch"
return name
}
function toolDetail(
name: string,
args: Record<string, unknown>,
result: Record<string, unknown>,
): string {
if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) {
return compact(args.command ?? args.cmd ?? result.output)
}
if (/^(?:read_file|write_file|edit_file|apply_patch|read|write|edit)$/u.test(name)) {
return compact(args.path ?? args.file_path ?? result.path)
}
if (name === "web_search") return compact(args.query ?? args.q)
if (name === "web_fetch") return compact(args.url)
if (/session/u.test(name)) return compact(args.session_key ?? args.chat_id ?? args.query)
if (Object.keys(args).length) return compact(args)
return Object.keys(result).length ? compact(result) : ""
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {}
}
function compact(value: unknown): string {
if (value == null || value === "") return ""
const text = typeof value === "string" ? value : JSON.stringify(value)
return text.length > 88 ? `${text.slice(0, 85)}` : text
}
+21 -25
View File
@@ -12,6 +12,7 @@ import {
import type { FileEditEvent, HistoryMessage, ToolProgressEvent } from "./protocol"
import { hideScrollbars } from "./scrollbox"
import { mergeToolEvent, renderToolEvent } from "./tool-renderers"
export interface TranscriptTheme {
text: string
@@ -40,6 +41,7 @@ interface Activity {
lines: string[]
keys: Map<string, number>
expanded: boolean
events: Map<string, ToolProgressEvent>
}
const ACTIVITY_PREVIEW_LINES = 6
@@ -182,8 +184,8 @@ export class Transcript {
? message.fileEdits.map((edit) => ({
call_id: `file:${edit.call_id || edit.path || "unknown"}`,
phase: edit.status === "error" ? "error" : edit.phase,
name: edit.path ? `${edit.tool || "edit"} ${edit.path}` : "edit file",
arguments: edit.error || formatDiffStat(edit),
name: edit.tool || "edit_file",
arguments: { path: edit.path, stat: edit.error || formatDiffStat(edit) },
}))
: message.toolEvents || []
this.updateActivity(activity, message.content, events)
@@ -260,8 +262,8 @@ export class Transcript {
return this.progress("", edits.map((edit) => ({
call_id: `file:${edit.call_id || edit.path || "unknown"}`,
phase: edit.status === "error" ? "error" : edit.phase,
name: edit.path ? `${edit.tool || "edit"} ${edit.path}` : "edit file",
arguments: edit.error || formatDiffStat(edit),
name: edit.tool || "edit_file",
arguments: { path: edit.path, stat: edit.error || formatDiffStat(edit) },
})))
}
@@ -360,7 +362,13 @@ export class Transcript {
this.root.add(row, index)
this.styledText.push({ renderable: text, tone: "muted" })
this.wrote = true
const activity = { text, lines: [], keys: new Map(), expanded: false }
const activity = {
text,
lines: [],
keys: new Map<string, number>(),
expanded: false,
events: new Map<string, ToolProgressEvent>(),
}
this.activities.add(activity)
return activity
}
@@ -370,11 +378,17 @@ export class Transcript {
content: string,
events: ToolProgressEvent[] = [],
): string {
const projected = events.map((event) => {
const key = event.call_id ? `tool:${event.call_id}` : ""
const merged = key ? mergeToolEvent(activity.events.get(key), event) : event
if (key) activity.events.set(key, merged)
return { key, line: renderToolEvent(merged) }
})
const lines = events.length > 0
? events.map(formatToolEvent).filter(Boolean)
? projected.map(({ line }) => line).filter(Boolean)
: content.split("\n").map(cleanProgress).filter(Boolean)
for (const [index, line] of lines.entries()) {
const key = events[index]?.call_id ? `tool:${events[index]?.call_id}` : undefined
const key = projected[index]?.key || undefined
const existing = key ? activity.keys.get(key) : undefined
if (existing !== undefined) {
activity.lines[existing] = line
@@ -479,24 +493,6 @@ function cleanProgress(value: string): string {
return text ? ` · ${text}` : ""
}
function formatToolEvent(event: ToolProgressEvent): string {
const phase = event.phase || "start"
const marker = phase === "error" ? "×" : phase === "end" ? "✓" : ""
const name = event.name?.trim() || "tool"
const detail = phase === "error"
? compactValue(event.error)
: phase === "start"
? compactValue(event.arguments)
: ""
return ` ${marker} ${name}${detail ? ` ${detail}` : ""}`
}
function compactValue(value: unknown): string {
if (value == null || value === "") return ""
const text = typeof value === "string" ? value : JSON.stringify(value)
return text.length > 72 ? `${text.slice(0, 69)}` : text
}
function formatDiffStat(edit: FileEditEvent): string {
const added = typeof edit.added === "number" ? `+${edit.added}` : ""
const deleted = typeof edit.deleted === "number" ? `-${edit.deleted}` : ""