mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
feat(tui): add agent interaction workflows
This commit is contained in:
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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" },
|
||||
])
|
||||
})
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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}` : ""
|
||||
|
||||
Reference in New Issue
Block a user