mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 10:11:46 +03:00
fix(tui): harden the interactive terminal experience
This commit is contained in:
+265
-190
@@ -1,26 +1,24 @@
|
||||
import {
|
||||
BoxRenderable,
|
||||
CliRenderEvents,
|
||||
MarkdownRenderable,
|
||||
RGBA,
|
||||
ScrollBoxRenderable,
|
||||
SyntaxStyle,
|
||||
TextareaRenderable,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
createCliRenderer,
|
||||
getTreeSitterClient,
|
||||
type CliRenderer,
|
||||
type KeyEvent,
|
||||
type TreeSitterClient,
|
||||
} from "@opentui/core"
|
||||
|
||||
import {
|
||||
NanobotClient,
|
||||
fetchHistory,
|
||||
type ConnectionStatus,
|
||||
type HistoryMessage,
|
||||
type InboundEvent,
|
||||
} from "./protocol"
|
||||
import { Transcript, type TranscriptTheme } from "./transcript"
|
||||
|
||||
interface AppOptions {
|
||||
wsUrl: string
|
||||
@@ -33,6 +31,13 @@ interface AppOptions {
|
||||
access: string
|
||||
}
|
||||
|
||||
interface ChatClient {
|
||||
readonly activeChatId: string
|
||||
connect(): void
|
||||
close(): void
|
||||
send(content: string): string
|
||||
}
|
||||
|
||||
interface Palette {
|
||||
background: string
|
||||
panel: string
|
||||
@@ -98,170 +103,46 @@ function syntaxStyle(palette: Palette): SyntaxStyle {
|
||||
})
|
||||
}
|
||||
|
||||
class Transcript {
|
||||
readonly root: ScrollBoxRenderable
|
||||
private live: { row: BoxRenderable; text: TextRenderable; content: string } | null = null
|
||||
private wrote = false
|
||||
private nextId = 0
|
||||
|
||||
constructor(
|
||||
private readonly renderer: CliRenderer,
|
||||
private palette: Palette,
|
||||
) {
|
||||
this.root = new ScrollBoxRenderable(renderer, {
|
||||
id: "nanobot-tui-transcript",
|
||||
width: "100%",
|
||||
minHeight: 0,
|
||||
flexGrow: 1,
|
||||
scrollX: false,
|
||||
scrollY: true,
|
||||
stickyScroll: true,
|
||||
stickyStart: "bottom",
|
||||
viewportCulling: true,
|
||||
contentOptions: {
|
||||
flexDirection: "column",
|
||||
paddingTop: 1,
|
||||
paddingBottom: 1,
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
},
|
||||
verticalScrollbarOptions: { visible: false },
|
||||
horizontalScrollbarOptions: { visible: false },
|
||||
})
|
||||
// Constructor options are applied before ScrollBarRenderable starts managing
|
||||
// its own visibility. Assigning through the setters keeps both bars hidden.
|
||||
this.root.verticalScrollBar.visible = false
|
||||
this.root.horizontalScrollBar.visible = false
|
||||
function transcriptTheme(palette: Palette): TranscriptTheme {
|
||||
return {
|
||||
text: palette.text,
|
||||
muted: palette.muted,
|
||||
error: palette.error,
|
||||
user: palette.user,
|
||||
border: palette.border,
|
||||
syntax: syntaxStyle(palette),
|
||||
}
|
||||
}
|
||||
|
||||
setPalette(palette: Palette): void {
|
||||
this.palette = palette
|
||||
}
|
||||
function formatElapsed(milliseconds: number): string {
|
||||
const seconds = Math.max(0, Math.floor(milliseconds / 1000))
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`
|
||||
}
|
||||
|
||||
header(options: AppOptions): void {
|
||||
const lines = [
|
||||
`>_ nanobot v${options.version}`,
|
||||
`${options.model} · ${options.access}`,
|
||||
options.workspace,
|
||||
]
|
||||
this.writeText(lines.join("\n"), this.palette.text, true, true)
|
||||
}
|
||||
|
||||
async history(messages: HistoryMessage[]): Promise<void> {
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") this.user(message.content)
|
||||
else this.assistant(message.content)
|
||||
async function copyWithSystemClipboard(text: string): Promise<void> {
|
||||
const commands = process.platform === "darwin"
|
||||
? [["pbcopy"]]
|
||||
: process.platform === "win32"
|
||||
? [["clip.exe"]]
|
||||
: [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]
|
||||
for (const command of commands) {
|
||||
try {
|
||||
const child = Bun.spawn(command, { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||
child.stdin.write(text)
|
||||
child.stdin.end()
|
||||
if (await child.exited === 0) return
|
||||
} catch {
|
||||
// Try the next platform clipboard provider.
|
||||
}
|
||||
}
|
||||
|
||||
user(content: string): void {
|
||||
this.writeText(`› ${content}`, this.palette.user, true)
|
||||
}
|
||||
|
||||
assistant(content: string): void {
|
||||
if (!content.trim()) return
|
||||
this.writeMarkdown(content)
|
||||
}
|
||||
|
||||
notice(content: string, error = false): void {
|
||||
this.writeText(content, error ? this.palette.error : this.palette.muted)
|
||||
}
|
||||
|
||||
stream(delta: string): void {
|
||||
if (!delta) return
|
||||
if (!this.live) {
|
||||
const row = this.createRow()
|
||||
const text = new TextRenderable(this.renderer, {
|
||||
id: this.id("assistant-stream"),
|
||||
content: "",
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
fg: this.palette.text,
|
||||
})
|
||||
row.add(text)
|
||||
this.root.add(row)
|
||||
this.live = { row, text, content: "" }
|
||||
this.wrote = true
|
||||
}
|
||||
this.live.content += delta
|
||||
this.live.text.content = this.live.content
|
||||
}
|
||||
|
||||
finishStream(fallback = ""): void {
|
||||
const content = this.live?.content || fallback
|
||||
if (this.live) {
|
||||
this.root.remove(this.live.row)
|
||||
this.live.row.destroy()
|
||||
this.live = null
|
||||
}
|
||||
if (content.trim()) this.assistant(content)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.live = null
|
||||
}
|
||||
|
||||
private id(prefix: string): string {
|
||||
this.nextId += 1
|
||||
return `${prefix}-${this.nextId}`
|
||||
}
|
||||
|
||||
private createRow(framed = false): BoxRenderable {
|
||||
return new BoxRenderable(this.renderer, {
|
||||
id: this.id(framed ? "text-frame" : "text-row"),
|
||||
width: "100%",
|
||||
marginTop: this.wrote ? 1 : 0,
|
||||
border: framed,
|
||||
borderStyle: "rounded",
|
||||
borderColor: this.palette.border,
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
flexDirection: "column",
|
||||
})
|
||||
}
|
||||
|
||||
private writeText(
|
||||
content: string,
|
||||
color: string,
|
||||
bold = false,
|
||||
framed = false,
|
||||
): void {
|
||||
const row = this.createRow(framed)
|
||||
row.add(
|
||||
new TextRenderable(this.renderer, {
|
||||
id: this.id("text"),
|
||||
content,
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
fg: color,
|
||||
attributes: bold ? TextAttributes.BOLD : 0,
|
||||
}),
|
||||
)
|
||||
this.root.add(row)
|
||||
this.wrote = true
|
||||
}
|
||||
|
||||
private writeMarkdown(content: string): void {
|
||||
const row = this.createRow()
|
||||
const markdown = new MarkdownRenderable(this.renderer, {
|
||||
id: this.id("markdown"),
|
||||
content,
|
||||
width: "100%",
|
||||
syntaxStyle: syntaxStyle(this.palette),
|
||||
streaming: false,
|
||||
internalBlockMode: "top-level",
|
||||
treeSitterClient: getTreeSitterClient(),
|
||||
})
|
||||
row.add(markdown)
|
||||
this.root.add(row)
|
||||
this.wrote = true
|
||||
}
|
||||
throw new Error("no clipboard provider available")
|
||||
}
|
||||
|
||||
export class NanobotTui {
|
||||
private readonly renderer: CliRenderer
|
||||
private readonly transcript: Transcript
|
||||
private readonly client: NanobotClient
|
||||
private readonly client: ChatClient
|
||||
private readonly shell: BoxRenderable
|
||||
private readonly title: TextRenderable
|
||||
private readonly composerFrame: BoxRenderable
|
||||
@@ -270,22 +151,37 @@ export class NanobotTui {
|
||||
private readonly meta: TextRenderable
|
||||
private palette: Palette
|
||||
private activeTurn = false
|
||||
private activeLabel = "Thinking"
|
||||
private activeStartedAt = 0
|
||||
private lastProgress = ""
|
||||
private finalMessage = ""
|
||||
private turnHadAnswer = false
|
||||
private historyLoaded = false
|
||||
private attachedOnce = false
|
||||
private pendingEvents: InboundEvent[] | null = null
|
||||
private hydrationId = 0
|
||||
private ready = false
|
||||
private shimmerFrame = 0
|
||||
private shimmerTimer: ReturnType<typeof setInterval> | null = null
|
||||
private submitPending = false
|
||||
private readonly promptHistory: string[] = []
|
||||
private historyCursor = 0
|
||||
private historyDraft = ""
|
||||
private quitting = false
|
||||
|
||||
private constructor(renderer: CliRenderer, private readonly options: AppOptions) {
|
||||
private constructor(
|
||||
renderer: CliRenderer,
|
||||
private readonly options: AppOptions,
|
||||
client?: ChatClient,
|
||||
treeSitterClient = getTreeSitterClient(),
|
||||
) {
|
||||
this.renderer = renderer
|
||||
this.palette = renderer.themeMode === "light" ? LIGHT : DARK
|
||||
this.transcript = new Transcript(renderer, this.palette)
|
||||
this.client = new NanobotClient({
|
||||
this.transcript = new Transcript(renderer, transcriptTheme(this.palette), treeSitterClient)
|
||||
this.client = client || new NanobotClient({
|
||||
url: options.wsUrl,
|
||||
chatId: options.chatId,
|
||||
onEvent: (event) => this.handleEvent(event),
|
||||
onEvent: (event) => this.accept(event),
|
||||
onStatus: (status, detail) => this.handleStatus(status, detail),
|
||||
})
|
||||
|
||||
@@ -309,7 +205,7 @@ export class NanobotTui {
|
||||
this.composerFrame = new BoxRenderable(renderer, {
|
||||
id: "nanobot-tui-composer-frame",
|
||||
width: "100%",
|
||||
height: 3,
|
||||
minHeight: 3,
|
||||
flexShrink: 0,
|
||||
border: true,
|
||||
borderStyle: "rounded",
|
||||
@@ -322,7 +218,7 @@ export class NanobotTui {
|
||||
id: "nanobot-tui-composer",
|
||||
width: "100%",
|
||||
minHeight: 1,
|
||||
flexGrow: 1,
|
||||
maxHeight: 8,
|
||||
wrapMode: "word",
|
||||
placeholder: "Ask nanobot anything",
|
||||
placeholderColor: this.palette.faint,
|
||||
@@ -336,7 +232,10 @@ export class NanobotTui {
|
||||
{ name: "return", action: "submit" },
|
||||
{ name: "return", meta: true, action: "newline" },
|
||||
],
|
||||
onSubmit: () => this.submit(),
|
||||
onContentChange: () => this.resizeComposer(),
|
||||
// IMEs may commit their final composed glyph after Enter. Matching the
|
||||
// OpenCode/OpenTUI integration, defer twice before reading plainText.
|
||||
onSubmit: () => this.deferSubmit(),
|
||||
})
|
||||
this.status = new TextRenderable(renderer, {
|
||||
id: "nanobot-tui-status",
|
||||
@@ -373,6 +272,7 @@ export class NanobotTui {
|
||||
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleTheme)
|
||||
this.renderer.on(CliRenderEvents.RESIZE, this.handleResize)
|
||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.renderer.console.onCopySelection = (text) => void this.copySelection(text)
|
||||
this.handleResize()
|
||||
this.composer.focus()
|
||||
this.transcript.header(options)
|
||||
@@ -390,8 +290,13 @@ export class NanobotTui {
|
||||
return NanobotTui.mount(renderer, options)
|
||||
}
|
||||
|
||||
static mount(renderer: CliRenderer, options: AppOptions): NanobotTui {
|
||||
return new NanobotTui(renderer, options)
|
||||
static mount(
|
||||
renderer: CliRenderer,
|
||||
options: AppOptions,
|
||||
client?: ChatClient,
|
||||
treeSitterClient?: TreeSitterClient,
|
||||
): NanobotTui {
|
||||
return new NanobotTui(renderer, options, client, treeSitterClient)
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -399,7 +304,21 @@ export class NanobotTui {
|
||||
this.renderer.start()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.quit()
|
||||
}
|
||||
|
||||
private deferSubmit(): void {
|
||||
if (this.submitPending) return
|
||||
this.submitPending = true
|
||||
setTimeout(() => setTimeout(() => {
|
||||
this.submitPending = false
|
||||
this.submit()
|
||||
}, 0), 0)
|
||||
}
|
||||
|
||||
private submit(): void {
|
||||
if (this.quitting) return
|
||||
const content = this.composer.plainText.trim()
|
||||
if (!content) return
|
||||
if (!this.ready) {
|
||||
@@ -421,15 +340,32 @@ export class NanobotTui {
|
||||
return
|
||||
}
|
||||
this.composer.setText("")
|
||||
if (this.promptHistory.at(-1) !== content) this.promptHistory.push(content)
|
||||
if (this.promptHistory.length > 50) this.promptHistory.shift()
|
||||
this.historyCursor = this.promptHistory.length
|
||||
this.historyDraft = ""
|
||||
this.transcript.user(content)
|
||||
this.finalMessage = ""
|
||||
this.turnHadAnswer = false
|
||||
this.lastProgress = ""
|
||||
this.activeLabel = "Thinking"
|
||||
this.setActive(true)
|
||||
}
|
||||
|
||||
private handleEvent(event: InboundEvent): void {
|
||||
accept(event: InboundEvent): void {
|
||||
if (event.event === "attached") {
|
||||
void this.prepareChat(event.chat_id)
|
||||
const restoring = this.attachedOnce
|
||||
this.attachedOnce = true
|
||||
if (restoring) this.setActive(false)
|
||||
const queuesEvents = restoring || (!this.historyLoaded && Boolean(this.options.chatId))
|
||||
if (queuesEvents) {
|
||||
this.ready = false
|
||||
this.pendingEvents = []
|
||||
}
|
||||
const hydrationId = ++this.hydrationId
|
||||
void this.prepareChat(event.chat_id, restoring, hydrationId).then(() => {
|
||||
if (hydrationId === this.hydrationId) this.flushPendingEvents()
|
||||
})
|
||||
return
|
||||
}
|
||||
if (
|
||||
@@ -438,37 +374,68 @@ export class NanobotTui {
|
||||
&& this.client.activeChatId
|
||||
&& event.chat_id !== this.client.activeChatId
|
||||
) return
|
||||
if (this.pendingEvents) {
|
||||
this.pendingEvents.push(event)
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.event) {
|
||||
case "message_accepted":
|
||||
return
|
||||
case "delta":
|
||||
this.setActive(true)
|
||||
this.activeLabel = "Writing"
|
||||
this.turnHadAnswer = true
|
||||
this.transcript.stream(event.text)
|
||||
return
|
||||
case "message":
|
||||
if (event.kind) {
|
||||
this.lastProgress = event.text.trim()
|
||||
if (this.lastProgress) this.status.content = this.lastProgress
|
||||
this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking"
|
||||
this.lastProgress = this.transcript.progress(event.text, event.tool_events)
|
||||
this.setActive(true)
|
||||
} else {
|
||||
this.finalMessage = event.text
|
||||
}
|
||||
return
|
||||
case "reasoning_delta":
|
||||
case "file_edit":
|
||||
this.activeLabel = "Editing"
|
||||
this.lastProgress = this.transcript.fileEdits(event.edits)
|
||||
this.setActive(true)
|
||||
return
|
||||
case "reasoning_delta":
|
||||
this.activeLabel = "Thinking"
|
||||
this.setActive(true)
|
||||
return
|
||||
case "reasoning_end":
|
||||
return
|
||||
case "stream_end":
|
||||
if (event.text) this.finalMessage = event.text
|
||||
if (event.text && !this.turnHadAnswer) this.turnHadAnswer = true
|
||||
if (event.resuming && event.merge_next) {
|
||||
this.transcript.reconcileStream(event.text || "")
|
||||
} else {
|
||||
this.transcript.finishStream(event.text || "")
|
||||
}
|
||||
return
|
||||
case "turn_end":
|
||||
this.transcript.finishStream(this.finalMessage)
|
||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||
this.transcript.finishActivity()
|
||||
this.finalMessage = ""
|
||||
this.turnHadAnswer = false
|
||||
this.setActive(false)
|
||||
if (typeof event.latency_ms === "number") {
|
||||
this.status.content = `Ready · ${(event.latency_ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
return
|
||||
case "goal_status":
|
||||
if (event.status === "running") {
|
||||
this.activeLabel = "Working"
|
||||
this.setActive(true, typeof event.started_at === "number" ? event.started_at * 1000 : undefined)
|
||||
} else {
|
||||
this.setActive(false)
|
||||
}
|
||||
return
|
||||
case "goal_state":
|
||||
return
|
||||
case "turn_model_updated":
|
||||
this.title.content = `nanobot · ${event.model_name}`
|
||||
return
|
||||
@@ -476,54 +443,89 @@ export class NanobotTui {
|
||||
this.title.content = `nanobot · ${event.model_name}`
|
||||
return
|
||||
case "error":
|
||||
this.transcript.finishStream(this.finalMessage)
|
||||
this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
|
||||
this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
|
||||
this.finalMessage = ""
|
||||
this.turnHadAnswer = false
|
||||
this.setActive(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareChat(chatId: string): Promise<void> {
|
||||
private async prepareChat(chatId: string, restoring: boolean, hydrationId: number): Promise<void> {
|
||||
try {
|
||||
if (!this.historyLoaded && this.options.chatId) {
|
||||
if (restoring) {
|
||||
this.transcript.reset({
|
||||
model: this.options.model,
|
||||
workspace: this.options.workspace,
|
||||
version: this.options.version,
|
||||
access: this.options.access,
|
||||
})
|
||||
}
|
||||
if (restoring || (!this.historyLoaded && this.options.chatId)) {
|
||||
this.historyLoaded = true
|
||||
const messages = await fetchHistory(this.options.apiUrl, this.options.apiToken, chatId)
|
||||
await this.transcript.history(messages)
|
||||
const history = await fetchHistory(this.options.apiUrl, this.options.apiToken, chatId)
|
||||
if (hydrationId !== this.hydrationId) return
|
||||
if (history.truncated) {
|
||||
this.transcript.notice("Earlier messages omitted · open WebUI to load the full history")
|
||||
}
|
||||
this.transcript.history(history.messages)
|
||||
}
|
||||
} catch (error) {
|
||||
if (hydrationId !== this.hydrationId) return
|
||||
this.transcript.notice(error instanceof Error ? error.message : String(error), true)
|
||||
} finally {
|
||||
if (hydrationId !== this.hydrationId) return
|
||||
this.ready = true
|
||||
this.status.content = "Ready"
|
||||
if (!this.activeTurn) this.status.content = "Ready"
|
||||
}
|
||||
}
|
||||
|
||||
private flushPendingEvents(): void {
|
||||
const events = this.pendingEvents
|
||||
this.pendingEvents = null
|
||||
for (const event of events || []) this.accept(event)
|
||||
}
|
||||
|
||||
private handleStatus(status: ConnectionStatus, detail?: string): void {
|
||||
if (status === "connected") {
|
||||
this.ready = false
|
||||
this.status.content = "Connected · preparing chat…"
|
||||
return
|
||||
}
|
||||
if (status === "connecting") {
|
||||
this.status.content = "Connecting…"
|
||||
this.ready = false
|
||||
if (detail) this.setActive(false)
|
||||
this.status.content = detail ? "Reconnecting…" : "Connecting…"
|
||||
return
|
||||
}
|
||||
if (status === "error") {
|
||||
this.setActive(false)
|
||||
this.status.content = detail || "Connection error"
|
||||
return
|
||||
}
|
||||
if (!this.quitting) this.status.content = "Disconnected"
|
||||
if (!this.quitting) {
|
||||
this.setActive(false)
|
||||
this.status.content = "Disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
private setActive(active: boolean): void {
|
||||
if (this.activeTurn === active) return
|
||||
private setActive(active: boolean, startedAt?: number): void {
|
||||
if (this.activeTurn === active) {
|
||||
if (active && startedAt !== undefined) this.activeStartedAt = startedAt
|
||||
return
|
||||
}
|
||||
this.activeTurn = active
|
||||
if (active) {
|
||||
this.activeStartedAt = startedAt ?? Date.now()
|
||||
this.shimmerFrame = 0
|
||||
this.shimmerTimer = setInterval(() => {
|
||||
const dots = "·".repeat((this.shimmerFrame++ % 3) + 1)
|
||||
const detail = this.lastProgress ? ` ${this.lastProgress}` : ""
|
||||
this.status.content = `Working ${dots}${detail}`
|
||||
}, 260)
|
||||
const frames = ["◐", "◓", "◑", "◒"]
|
||||
const frame = frames[this.shimmerFrame++ % frames.length]
|
||||
const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
|
||||
const detail = this.lastProgress ? ` · ${this.lastProgress.replace(/^\s*[·›✓×]\s*/u, "")}` : ""
|
||||
this.status.content = `${frame} ${this.activeLabel} ${elapsed}${detail}`
|
||||
}, 120)
|
||||
return
|
||||
}
|
||||
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
|
||||
@@ -533,8 +535,34 @@ export class NanobotTui {
|
||||
}
|
||||
|
||||
private handleKey = (key: KeyEvent): void => {
|
||||
if (key.ctrl && key.name === "o") {
|
||||
const expanded = this.transcript.toggleActivityDetails()
|
||||
if (expanded === null) return
|
||||
this.status.content = expanded ? "Tool details expanded" : "Tool details collapsed"
|
||||
key.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
|
||||
const direction = key.name === "up" ? -1 : 1
|
||||
const boundary = direction < 0 ? 0 : this.composer.plainText.length
|
||||
if (this.composer.cursorOffset !== boundary) {
|
||||
const visualRow = this.composer.scrollY + this.composer.visualCursor.visualRow
|
||||
const edgeRow = direction < 0 ? 0 : Math.max(0, this.composer.virtualLineCount - 1)
|
||||
if (visualRow === edgeRow) this.composer.cursorOffset = boundary
|
||||
return
|
||||
}
|
||||
if (this.navigateHistory(direction)) {
|
||||
key.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (key.ctrl && key.name === "c") {
|
||||
key.preventDefault()
|
||||
const selected = this.renderer.getSelection()?.getSelectedText()
|
||||
if (selected) {
|
||||
void this.copySelection(selected)
|
||||
return
|
||||
}
|
||||
if (this.activeTurn) {
|
||||
try {
|
||||
this.client.send("/stop")
|
||||
@@ -552,12 +580,40 @@ export class NanobotTui {
|
||||
if (key.ctrl && key.name === "d" && !this.composer.plainText) {
|
||||
key.preventDefault()
|
||||
this.quit()
|
||||
return
|
||||
}
|
||||
if (key.name === "pageup" || key.name === "pagedown") {
|
||||
key.preventDefault()
|
||||
this.transcript.scrollByPage(key.name === "pageup" ? -1 : 1)
|
||||
return
|
||||
}
|
||||
if (key.ctrl && (key.name === "home" || key.name === "end")) {
|
||||
key.preventDefault()
|
||||
this.transcript.scrollToEdge(key.name === "home" ? "top" : "bottom")
|
||||
}
|
||||
}
|
||||
|
||||
private navigateHistory(direction: -1 | 1): boolean {
|
||||
if (this.promptHistory.length === 0) return false
|
||||
if (direction < 0) {
|
||||
if (this.historyCursor === this.promptHistory.length) this.historyDraft = this.composer.plainText
|
||||
if (this.historyCursor === 0) return false
|
||||
this.historyCursor -= 1
|
||||
} else {
|
||||
if (this.historyCursor >= this.promptHistory.length) return false
|
||||
this.historyCursor += 1
|
||||
}
|
||||
const content = this.historyCursor === this.promptHistory.length
|
||||
? this.historyDraft
|
||||
: this.promptHistory[this.historyCursor] || ""
|
||||
this.composer.setText(content)
|
||||
this.composer.cursorOffset = direction < 0 ? 0 : content.length
|
||||
return true
|
||||
}
|
||||
|
||||
private handleTheme = (): void => {
|
||||
this.palette = this.renderer.themeMode === "light" ? LIGHT : DARK
|
||||
this.transcript.setPalette(this.palette)
|
||||
this.transcript.setTheme(transcriptTheme(this.palette))
|
||||
this.renderer.setBackgroundColor(this.palette.background)
|
||||
this.shell.backgroundColor = this.palette.background
|
||||
this.composerFrame.backgroundColor = this.palette.panel
|
||||
@@ -573,13 +629,32 @@ export class NanobotTui {
|
||||
}
|
||||
|
||||
private handleResize = (): void => {
|
||||
this.resizeComposer()
|
||||
this.title.visible = this.renderer.height >= 12
|
||||
this.meta.content = this.renderer.width >= 72
|
||||
? "enter send · alt+enter newline · ctrl+c stop"
|
||||
? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop"
|
||||
: this.renderer.width >= 48
|
||||
? "enter send · alt+enter newline"
|
||||
: ""
|
||||
}
|
||||
|
||||
private resizeComposer(): void {
|
||||
const maxHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3)))
|
||||
this.composer.maxHeight = maxHeight
|
||||
this.composerFrame.maxHeight = maxHeight + 2
|
||||
}
|
||||
|
||||
private async copySelection(text: string): Promise<void> {
|
||||
if (!text) return
|
||||
try {
|
||||
if (!this.renderer.copyToClipboardOSC52(text)) await copyWithSystemClipboard(text)
|
||||
this.renderer.clearSelection()
|
||||
this.status.content = "Copied"
|
||||
} catch {
|
||||
this.status.content = "Copy unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
private quit(): void {
|
||||
if (this.quitting) return
|
||||
this.quitting = true
|
||||
|
||||
Reference in New Issue
Block a user