mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
feat(tui): make follow-up queue explicit
This commit is contained in:
@@ -202,7 +202,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
|
|||||||
nanobot agent
|
nanobot agent
|
||||||
```
|
```
|
||||||
|
|
||||||
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` queues a follow-up and a second empty `Enter` steers the current turn. Press `PageUp` at the top to load earlier transcript pages. The next launch returns to your last session unless `--session` selects another one. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. The client starts a local gateway only when needed and releases it when you exit. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
|
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` steers the current turn, `Tab` queues a visible follow-up for the next turn, and `Alt+Up` returns the latest queued message for editing. Press `Ctrl+J` to add a newline and `PageUp` at the top to load earlier transcript pages. The next launch returns to your last session unless `--session` selects another one. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. The client starts a local gateway only when needed and releases it when you exit. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
|
||||||
|
|
||||||
For one request and an immediate exit, use:
|
For one request and an immediate exit, use:
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the s
|
|||||||
|
|
||||||
The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably.
|
The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably.
|
||||||
|
|
||||||
`Enter` sends the current message. While a turn is active, it queues a follow-up; press `Enter` again on an empty composer to steer with the newest queued prompt. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Large pastes appear as a compact placeholder in the composer but are sent unchanged. Type `/` to discover nanobot commands and terminal navigation in one palette, or type `@` to complete installed apps, configured MCP servers, and saved sessions. Use the arrow keys to choose an item and `Tab` to complete it. `/sessions` opens a searchable conversation picker, `/new-chat` preserves the current conversation and starts another one, and `/branch` forks from a completed reply. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. When you leave the bottom, the TUI shows a scrollbar and a `Ctrl+End` hint until you return. The footer reports provider token/cache usage when available. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
|
`Enter` sends the current message. While a turn is active, `Enter` steers it immediately, `Tab` queues a visible follow-up for the next turn, and `Alt+Up` returns the latest queued message to the composer. Press `Ctrl+J` to add a newline (`Shift+Enter`, `Alt+Enter`, and `Ctrl+Enter` are accepted when the terminal can distinguish them) and use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Large pastes appear as a compact placeholder in the composer but are sent unchanged. Type `/` to discover nanobot commands and terminal navigation in one palette, or type `@` to complete installed apps, configured MCP servers, and saved sessions. Use the arrow keys to choose an item and `Tab` to complete it. `/sessions` opens a searchable conversation picker, `/new-chat` preserves the current conversation and starts another one, and `/branch` forks from a completed reply. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. When you leave the bottom, the TUI shows a scrollbar and a `Ctrl+End` hint until you return. The footer reports provider token/cache usage when available. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
|
||||||
|
|
||||||
Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`.
|
Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`.
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -112,7 +112,9 @@ Then start an interactive terminal chat with:
|
|||||||
nanobot agent
|
nanobot agent
|
||||||
```
|
```
|
||||||
|
|
||||||
In interactive mode, `Enter` sends and `Alt+Enter` inserts a newline. Exit with `exit`, `/exit`, `:q`, or `Ctrl+D`.
|
In interactive mode, `Enter` sends and `Ctrl+J` inserts a newline. While a turn is running,
|
||||||
|
`Enter` steers it, `Tab` queues a follow-up, and `Alt+Up` edits the latest queued message. Exit
|
||||||
|
with `exit`, `/exit`, `:q`, or `Ctrl+D`.
|
||||||
|
|
||||||
## Choose One Next Step
|
## Choose One Next Step
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
|
|||||||
self._popen = popen
|
self._popen = popen
|
||||||
self._subprocess_run = subprocess_run
|
self._subprocess_run = subprocess_run
|
||||||
self._sleep = sleep
|
self._sleep = sleep
|
||||||
|
# Keep the handle for children spawned by this runtime. On POSIX an
|
||||||
|
# exited child remains visible to kill(pid, 0) until its parent reaps
|
||||||
|
# it; poll() both reaps it and reports the real lifecycle state.
|
||||||
|
self._owned_process: Any | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def refresh_state_pid(cls, *, paths: ProcessRuntimePaths) -> None:
|
def refresh_state_pid(cls, *, paths: ProcessRuntimePaths) -> None:
|
||||||
@@ -125,6 +129,7 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
|
|||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
**self._popen_platform_kwargs(),
|
**self._popen_platform_kwargs(),
|
||||||
)
|
)
|
||||||
|
self._owned_process = process
|
||||||
|
|
||||||
pid = int(process.pid)
|
pid = int(process.pid)
|
||||||
self._sleep(0.2)
|
self._sleep(0.2)
|
||||||
@@ -335,6 +340,14 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
|
|||||||
def _is_pid_running(self, pid: int) -> bool:
|
def _is_pid_running(self, pid: int) -> bool:
|
||||||
if pid <= 0:
|
if pid <= 0:
|
||||||
return False
|
return False
|
||||||
|
owned_process = self._owned_process
|
||||||
|
if owned_process is not None and getattr(owned_process, "pid", None) == pid:
|
||||||
|
poll = getattr(owned_process, "poll", None)
|
||||||
|
if callable(poll):
|
||||||
|
try:
|
||||||
|
return poll() is None
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
if self.platform_name == "Windows":
|
if self.platform_name == "Windows":
|
||||||
return _windows_process_identity(pid) is not None
|
return _windows_process_identity(pid) is not None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ class FakeProcess:
|
|||||||
self.pid = pid
|
self.pid = pid
|
||||||
|
|
||||||
|
|
||||||
|
class PollableProcess(FakeProcess):
|
||||||
|
def __init__(self, pid: int = 12345):
|
||||||
|
super().__init__(pid)
|
||||||
|
self.returncode: int | None = None
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
|
||||||
def _paths(tmp_path: Path) -> GatewayRuntimePaths:
|
def _paths(tmp_path: Path) -> GatewayRuntimePaths:
|
||||||
return GatewayRuntimePaths.for_instance(data_dir=tmp_path)
|
return GatewayRuntimePaths.for_instance(data_dir=tmp_path)
|
||||||
|
|
||||||
@@ -87,6 +96,38 @@ def test_start_background_writes_state_and_child_command(tmp_path, monkeypatch):
|
|||||||
assert state["port"] == 18790
|
assert state["port"] == 18790
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_reaps_an_owned_child_without_consuming_the_shutdown_timeout(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
process = PollableProcess()
|
||||||
|
sleeps: list[float] = []
|
||||||
|
runtime = GatewayRuntime(
|
||||||
|
paths=_paths(tmp_path),
|
||||||
|
platform_name="Darwin",
|
||||||
|
popen=lambda *_args, **_kwargs: process,
|
||||||
|
sleep=sleeps.append,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.process_runtime.os.getpgid",
|
||||||
|
lambda _pid: process.pid,
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.process_runtime.os.killpg",
|
||||||
|
lambda _pgid, _signal: setattr(process, "returncode", -15),
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert runtime.start_background(GatewayStartOptions(port=18790)).ok is True
|
||||||
|
sleeps.clear()
|
||||||
|
result = runtime.stop(timeout_s=20)
|
||||||
|
|
||||||
|
assert result.ok is True
|
||||||
|
assert sleeps == []
|
||||||
|
|
||||||
|
|
||||||
def test_concurrent_background_starts_create_only_one_process(tmp_path, monkeypatch):
|
def test_concurrent_background_starts_create_only_one_process(tmp_path, monkeypatch):
|
||||||
first_spawned = threading.Event()
|
first_spawned = threading.Event()
|
||||||
release_first = threading.Event()
|
release_first = threading.Event()
|
||||||
|
|||||||
+5
-2
@@ -21,8 +21,11 @@ Type `/` to discover slash commands published by the connected gateway. Use the
|
|||||||
to move, `Tab` to complete, and `Esc` to close the menu.
|
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
|
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;
|
same gateway metadata used by the WebUI. While nanobot is working, `Enter` steers the current
|
||||||
press `Enter` again on an empty composer to steer the current turn with the newest queued prompt.
|
turn, `Tab` queues a follow-up for the next turn, and `Alt+Up` returns the latest queued message
|
||||||
|
to the composer for editing. The pending queue stays visible above the composer.
|
||||||
|
Use `Ctrl+J` for a newline; `Shift+Enter`, `Alt+Enter`, and `Ctrl+Enter` are also accepted when
|
||||||
|
the terminal can distinguish them.
|
||||||
Unsent prompts return to the composer if the turn stops or fails.
|
Unsent prompts return to the composer if the turn stops or fails.
|
||||||
|
|
||||||
Use `/sessions` to search and switch persisted conversations without leaving the terminal.
|
Use `/sessions` to search and switch persisted conversations without leaving the terminal.
|
||||||
|
|||||||
+57
-6
@@ -167,6 +167,32 @@ describe("NanobotTui layout", () => {
|
|||||||
expect(sent).toEqual(["你好"])
|
expect(sent).toEqual(["你好"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("inserts a newline without sending and gives the composer breathing room", async () => {
|
||||||
|
const sent: string[] = []
|
||||||
|
setup = await createRenderer({
|
||||||
|
width: 72,
|
||||||
|
height: 20,
|
||||||
|
screenMode: "alternate-screen",
|
||||||
|
kittyKeyboard: true,
|
||||||
|
})
|
||||||
|
const app = mount(setup, sent)
|
||||||
|
app.accept({ event: "attached", chat_id: "chat" })
|
||||||
|
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
|
||||||
|
const ui = app as unknown as {
|
||||||
|
composer: TextareaRenderable
|
||||||
|
composerFrame: { height: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
await setup.mockInput.typeText("first")
|
||||||
|
setup.mockInput.pressKey("j", { ctrl: true })
|
||||||
|
await setup.mockInput.typeText("second")
|
||||||
|
await setup.flush()
|
||||||
|
|
||||||
|
expect(ui.composer.plainText).toBe("first\nsecond")
|
||||||
|
expect(sent).toEqual([])
|
||||||
|
expect(ui.composerFrame.height).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
|
||||||
test("clears the placeholder on the first typed character", async () => {
|
test("clears the placeholder on the first typed character", async () => {
|
||||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||||
const app = mount(setup)
|
const app = mount(setup)
|
||||||
@@ -214,7 +240,7 @@ describe("NanobotTui layout", () => {
|
|||||||
expect(ui.composer.plainText).toBe("")
|
expect(ui.composer.plainText).toBe("")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("queues follow-ups and promotes the armed prompt to steering", async () => {
|
test("steers with Enter, queues with Tab, and restores queued text with Alt+Up", async () => {
|
||||||
const sent: string[] = []
|
const sent: string[] = []
|
||||||
const sentOptions: MessageOptions[] = []
|
const sentOptions: MessageOptions[] = []
|
||||||
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
|
||||||
@@ -229,6 +255,7 @@ describe("NanobotTui layout", () => {
|
|||||||
ready: boolean
|
ready: boolean
|
||||||
composer: TextareaRenderable
|
composer: TextareaRenderable
|
||||||
mentionCandidates: Array<Record<string, unknown>>
|
mentionCandidates: Array<Record<string, unknown>>
|
||||||
|
queuePreview: { root: { visible: boolean } }
|
||||||
}
|
}
|
||||||
await waitUntil(() => ui.ready)
|
await waitUntil(() => ui.ready)
|
||||||
ui.mentionCandidates = [{
|
ui.mentionCandidates = [{
|
||||||
@@ -243,10 +270,6 @@ describe("NanobotTui layout", () => {
|
|||||||
await waitUntil(() => sent.length === 1)
|
await waitUntil(() => sent.length === 1)
|
||||||
|
|
||||||
ui.composer.setText("ask @github next")
|
ui.composer.setText("ask @github next")
|
||||||
ui.composer.submit()
|
|
||||||
await waitUntil(() => ui.composer.plainText === "")
|
|
||||||
expect(sent).toEqual(["first"])
|
|
||||||
|
|
||||||
ui.composer.submit()
|
ui.composer.submit()
|
||||||
await waitUntil(() => sent.length === 2)
|
await waitUntil(() => sent.length === 2)
|
||||||
expect(sentOptions[1]).toEqual({
|
expect(sentOptions[1]).toEqual({
|
||||||
@@ -256,8 +279,17 @@ describe("NanobotTui layout", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ui.composer.setText("after this turn")
|
ui.composer.setText("after this turn")
|
||||||
ui.composer.submit()
|
setup.mockInput.pressTab()
|
||||||
await waitUntil(() => ui.composer.plainText === "")
|
await waitUntil(() => ui.composer.plainText === "")
|
||||||
|
expect(sent).toHaveLength(2)
|
||||||
|
expect(ui.queuePreview.root.visible).toBeTrue()
|
||||||
|
|
||||||
|
setup.mockInput.pressArrow("up", { meta: true })
|
||||||
|
expect(ui.composer.plainText).toBe("after this turn")
|
||||||
|
expect(ui.queuePreview.root.visible).toBeFalse()
|
||||||
|
setup.mockInput.pressTab()
|
||||||
|
await waitUntil(() => ui.composer.plainText === "")
|
||||||
|
|
||||||
app.accept({
|
app.accept({
|
||||||
event: "error",
|
event: "error",
|
||||||
chat_id: "chat",
|
chat_id: "chat",
|
||||||
@@ -272,6 +304,7 @@ describe("NanobotTui layout", () => {
|
|||||||
app.accept({ event: "turn_end", chat_id: "chat", turn_id: "turn" })
|
app.accept({ event: "turn_end", chat_id: "chat", turn_id: "turn" })
|
||||||
await waitUntil(() => sent.length === 3)
|
await waitUntil(() => sent.length === 3)
|
||||||
expect(sent[2]).toBe("after this turn")
|
expect(sent[2]).toBe("after this turn")
|
||||||
|
expect(ui.queuePreview.root.visible).toBeFalse()
|
||||||
app.accept({ event: "goal_status", chat_id: "chat", status: "idle", turn_id: "prior" })
|
app.accept({ event: "goal_status", chat_id: "chat", status: "idle", turn_id: "prior" })
|
||||||
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBeTrue()
|
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBeTrue()
|
||||||
})
|
})
|
||||||
@@ -1558,6 +1591,24 @@ describe("NanobotTui layout", () => {
|
|||||||
expect(closed).toBe(true)
|
expect(closed).toBe(true)
|
||||||
expect(setup.renderer.isDestroyed).toBe(true)
|
expect(setup.renderer.isDestroyed).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("exits immediately when Ctrl+C is pressed on an idle empty composer", async () => {
|
||||||
|
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||||
|
let closed = false
|
||||||
|
const transport = client()
|
||||||
|
transport.close = () => { closed = true }
|
||||||
|
NanobotTui.mount(
|
||||||
|
setup.renderer,
|
||||||
|
options,
|
||||||
|
transport,
|
||||||
|
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||||
|
)
|
||||||
|
|
||||||
|
setup.mockInput.pressCtrlC()
|
||||||
|
|
||||||
|
expect(closed).toBe(true)
|
||||||
|
expect(setup.renderer.isDestroyed).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
|
|||||||
+109
-69
@@ -66,6 +66,12 @@ import {
|
|||||||
type MentionQuery,
|
type MentionQuery,
|
||||||
} from "./mention-menu"
|
} from "./mention-menu"
|
||||||
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
|
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
|
||||||
|
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
|
||||||
|
import {
|
||||||
|
contextualFooterHints,
|
||||||
|
type FooterMode,
|
||||||
|
type FooterHintTheme,
|
||||||
|
} from "./footer-hints"
|
||||||
|
|
||||||
interface AppOptions {
|
interface AppOptions {
|
||||||
wsUrl: string
|
wsUrl: string
|
||||||
@@ -248,6 +254,23 @@ function diffViewerTheme(palette: Palette, backgroundKnown: boolean): DiffViewer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queuePreviewTheme(palette: Palette): QueuePreviewTheme {
|
||||||
|
return {
|
||||||
|
accent: palette.accent,
|
||||||
|
muted: palette.muted,
|
||||||
|
faint: palette.faint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function footerHintTheme(palette: Palette): FooterHintTheme {
|
||||||
|
return {
|
||||||
|
accent: palette.accent,
|
||||||
|
danger: palette.error,
|
||||||
|
muted: palette.muted,
|
||||||
|
separator: palette.faint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function shimmerStatus(
|
function shimmerStatus(
|
||||||
label: string,
|
label: string,
|
||||||
suffix: string,
|
suffix: string,
|
||||||
@@ -329,6 +352,7 @@ export class NanobotTui {
|
|||||||
private readonly branchMenu: BranchMenu
|
private readonly branchMenu: BranchMenu
|
||||||
private readonly contextPanel: ContextPanel
|
private readonly contextPanel: ContextPanel
|
||||||
private readonly diffViewer: DiffViewer
|
private readonly diffViewer: DiffViewer
|
||||||
|
private readonly queuePreview: QueuePreview
|
||||||
private readonly client: ChatClient
|
private readonly client: ChatClient
|
||||||
private readonly shell: BoxRenderable
|
private readonly shell: BoxRenderable
|
||||||
private readonly title: BoxRenderable
|
private readonly title: BoxRenderable
|
||||||
@@ -422,6 +446,7 @@ export class NanobotTui {
|
|||||||
diffViewerTheme(this.palette, this.backgroundKnown),
|
diffViewerTheme(this.palette, this.backgroundKnown),
|
||||||
treeSitterClient,
|
treeSitterClient,
|
||||||
)
|
)
|
||||||
|
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
|
||||||
this.client = client || new NanobotClient({
|
this.client = client || new NanobotClient({
|
||||||
url: options.wsUrl,
|
url: options.wsUrl,
|
||||||
chatId: options.chatId,
|
chatId: options.chatId,
|
||||||
@@ -494,8 +519,12 @@ export class NanobotTui {
|
|||||||
cursorColor: this.palette.accent,
|
cursorColor: this.palette.accent,
|
||||||
showCursor: true,
|
showCursor: true,
|
||||||
keyBindings: [
|
keyBindings: [
|
||||||
{ name: "return", action: "submit" },
|
{ name: "return", shift: true, action: "newline" },
|
||||||
{ name: "return", meta: true, action: "newline" },
|
{ name: "return", meta: true, action: "newline" },
|
||||||
|
{ name: "return", ctrl: true, action: "newline" },
|
||||||
|
{ name: "j", ctrl: true, action: "newline" },
|
||||||
|
{ name: "linefeed", action: "newline" },
|
||||||
|
{ name: "return", action: "submit" },
|
||||||
],
|
],
|
||||||
onContentChange: () => {
|
onContentChange: () => {
|
||||||
this.draft.prune(this.composer.plainText)
|
this.draft.prune(this.composer.plainText)
|
||||||
@@ -523,7 +552,7 @@ export class NanobotTui {
|
|||||||
})
|
})
|
||||||
this.meta = new TextRenderable(renderer, {
|
this.meta = new TextRenderable(renderer, {
|
||||||
id: "nanobot-tui-meta",
|
id: "nanobot-tui-meta",
|
||||||
content: "enter send · alt+enter newline · ctrl+c stop",
|
content: "",
|
||||||
fg: this.palette.faint,
|
fg: this.palette.faint,
|
||||||
height: 1,
|
height: 1,
|
||||||
width: "auto",
|
width: "auto",
|
||||||
@@ -549,6 +578,7 @@ export class NanobotTui {
|
|||||||
this.shell.add(this.branchMenu.root)
|
this.shell.add(this.branchMenu.root)
|
||||||
this.shell.add(this.contextPanel.root)
|
this.shell.add(this.contextPanel.root)
|
||||||
this.shell.add(this.title)
|
this.shell.add(this.title)
|
||||||
|
this.shell.add(this.queuePreview.root)
|
||||||
this.shell.add(this.composerFrame)
|
this.shell.add(this.composerFrame)
|
||||||
this.shell.add(statusRow)
|
this.shell.add(statusRow)
|
||||||
this.shell.add(this.diffViewer.root)
|
this.shell.add(this.diffViewer.root)
|
||||||
@@ -640,13 +670,7 @@ export class NanobotTui {
|
|||||||
if (candidate) this.chooseMention(candidate, this.activeMentionQuery)
|
if (candidate) this.chooseMention(candidate, this.activeMentionQuery)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!visibleContent) {
|
if (!visibleContent) return
|
||||||
if (this.activeTurn) {
|
|
||||||
const steering = this.promptQueue.takeSteering()
|
|
||||||
if (steering) this.sendPrompt(steering, true)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const completion = this.commandMenu.completion(visibleContent)
|
const completion = this.commandMenu.completion(visibleContent)
|
||||||
if (completion) {
|
if (completion) {
|
||||||
this.setComposer(completion)
|
this.setComposer(completion)
|
||||||
@@ -678,13 +702,7 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
|
const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
|
||||||
if (this.activeTurn) {
|
if (this.activeTurn) {
|
||||||
this.promptQueue.enqueue(prompt)
|
this.sendPrompt(prompt, true)
|
||||||
this.clearComposer()
|
|
||||||
this.commandMenu.hide()
|
|
||||||
this.mentionMenu.hide()
|
|
||||||
this.recordPrompt(content)
|
|
||||||
this.renderActiveStatus()
|
|
||||||
this.updateMeta()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.sendPrompt(prompt)
|
this.sendPrompt(prompt)
|
||||||
@@ -1014,16 +1032,56 @@ export class NanobotTui {
|
|||||||
if (!this.ready || this.activeTurn || this.quitting) return
|
if (!this.ready || this.activeTurn || this.quitting) return
|
||||||
const prompt = this.promptQueue.takeFollowUp()
|
const prompt = this.promptQueue.takeFollowUp()
|
||||||
if (!prompt) return
|
if (!prompt) return
|
||||||
|
this.syncQueuePreview()
|
||||||
this.sendPrompt(prompt)
|
this.sendPrompt(prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
private restoreQueuedPrompts(): void {
|
private restoreQueuedPrompts(): void {
|
||||||
const queued = this.promptQueue.restore()
|
const queued = this.promptQueue.restore()
|
||||||
if (!queued.length) return
|
if (!queued.length) return
|
||||||
|
this.syncQueuePreview()
|
||||||
const current = this.draft.expand(this.composer.plainText).trim()
|
const current = this.draft.expand(this.composer.plainText).trim()
|
||||||
this.setComposer([current, ...queued.map((prompt) => prompt.content)].filter(Boolean).join("\n\n"))
|
this.setComposer([current, ...queued.map((prompt) => prompt.content)].filter(Boolean).join("\n\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private queueFollowUp(): void {
|
||||||
|
if (!this.activeTurn || !this.ready) return
|
||||||
|
const visibleContent = this.composer.plainText.trim()
|
||||||
|
const content = this.draft.expand(visibleContent).trim()
|
||||||
|
if (!content) return
|
||||||
|
this.promptQueue.enqueue({
|
||||||
|
content,
|
||||||
|
options: mentionOptions(content, this.availableMentions()),
|
||||||
|
})
|
||||||
|
this.clearComposer()
|
||||||
|
this.commandMenu.hide()
|
||||||
|
this.mentionMenu.hide()
|
||||||
|
this.recordPrompt(content)
|
||||||
|
this.syncQueuePreview()
|
||||||
|
this.renderActiveStatus()
|
||||||
|
this.updateMeta()
|
||||||
|
}
|
||||||
|
|
||||||
|
private editLastFollowUp(): boolean {
|
||||||
|
const prompt = this.promptQueue.takeLast()
|
||||||
|
if (!prompt) return false
|
||||||
|
const current = this.draft.expand(this.composer.plainText).trim()
|
||||||
|
this.setComposer([prompt.content, current].filter(Boolean).join("\n\n"))
|
||||||
|
this.syncQueuePreview()
|
||||||
|
this.renderActiveStatus()
|
||||||
|
this.updateMeta()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPromptQueue(): void {
|
||||||
|
this.promptQueue.clear()
|
||||||
|
this.syncQueuePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncQueuePreview(): void {
|
||||||
|
this.queuePreview.update(this.promptQueue.snapshot().map(({ content }) => content))
|
||||||
|
}
|
||||||
|
|
||||||
private handleTranscriptNavigation(state: TranscriptNavigation): void {
|
private handleTranscriptNavigation(state: TranscriptNavigation): void {
|
||||||
this.transcriptNavigation = state
|
this.transcriptNavigation = state
|
||||||
if (this.activeTurn) this.renderActiveStatus()
|
if (this.activeTurn) this.renderActiveStatus()
|
||||||
@@ -1124,6 +1182,15 @@ export class NanobotTui {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this.activeTurn && !key.ctrl && !key.meta && key.name === "tab") {
|
||||||
|
this.queueFollowUp()
|
||||||
|
key.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.activeTurn && key.meta && key.name === "up") {
|
||||||
|
if (this.editLastFollowUp()) key.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (key.ctrl && key.name === "o") {
|
if (key.ctrl && key.name === "o") {
|
||||||
const expanded = this.transcript.toggleActivityDetails()
|
const expanded = this.transcript.toggleActivityDetails()
|
||||||
if (expanded === null) return
|
if (expanded === null) return
|
||||||
@@ -1227,6 +1294,7 @@ export class NanobotTui {
|
|||||||
this.branchMenu.setTheme(commandMenuTheme(this.palette))
|
this.branchMenu.setTheme(commandMenuTheme(this.palette))
|
||||||
this.contextPanel.setTheme(contextPanelTheme(this.palette))
|
this.contextPanel.setTheme(contextPanelTheme(this.palette))
|
||||||
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
|
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
|
||||||
|
this.queuePreview.setTheme(queuePreviewTheme(this.palette))
|
||||||
this.updateComposerAppearance()
|
this.updateComposerAppearance()
|
||||||
this.composer.textColor = this.palette.text
|
this.composer.textColor = this.palette.text
|
||||||
this.composer.focusedTextColor = this.palette.text
|
this.composer.focusedTextColor = this.palette.text
|
||||||
@@ -1235,6 +1303,7 @@ export class NanobotTui {
|
|||||||
this.modelText.fg = this.palette.muted
|
this.modelText.fg = this.palette.muted
|
||||||
this.status.fg = this.palette.muted
|
this.status.fg = this.palette.muted
|
||||||
this.meta.fg = this.palette.faint
|
this.meta.fg = this.palette.faint
|
||||||
|
this.updateMeta()
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleResize = (): void => {
|
private handleResize = (): void => {
|
||||||
@@ -1247,53 +1316,19 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateMeta(): void {
|
private updateMeta(): void {
|
||||||
if (this.mentionMenu.visible) {
|
const mode: FooterMode = this.mentionMenu.visible ? "mention"
|
||||||
this.meta.content = this.renderer.width >= 64
|
: this.activeTurn ? "active"
|
||||||
? "↑↓ choose · tab/enter insert · esc close"
|
: this.branchMenu.visible ? "branch"
|
||||||
: "enter insert · esc close"
|
: this.commandMenu.visible ? "command"
|
||||||
return
|
: this.sessionMenu.visible ? "session"
|
||||||
}
|
: this.contextPanel.visible ? "context"
|
||||||
if (this.activeTurn) {
|
: this.transcriptNavigation.awayFromBottom ? "history"
|
||||||
this.meta.content = this.renderer.width >= 96
|
: "ready"
|
||||||
? "enter queue · enter again steer · ctrl+c stop"
|
this.meta.content = contextualFooterHints(
|
||||||
: this.renderer.width >= 64 ? "enter queue · ctrl+c stop" : ""
|
mode,
|
||||||
return
|
this.renderer.width,
|
||||||
}
|
footerHintTheme(this.palette),
|
||||||
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) {
|
|
||||||
this.meta.content = this.renderer.width >= 72
|
|
||||||
? "↑↓ choose · tab complete · esc close"
|
|
||||||
: "tab complete · esc close"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.sessionMenu.visible) {
|
|
||||||
this.meta.content = this.renderer.width >= 64
|
|
||||||
? "type to filter · ↑↓ choose · enter open · esc close"
|
|
||||||
: "enter open · esc close"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.contextPanel.visible) {
|
|
||||||
this.meta.content = "esc close · pgup/pgdn scroll"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.transcriptNavigation.awayFromBottom) {
|
|
||||||
this.meta.content = this.renderer.width >= 72
|
|
||||||
? "ctrl+end latest · pgup/pgdn scroll"
|
|
||||||
: this.renderer.width >= 48 ? "ctrl+end latest" : ""
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.meta.content = this.renderer.width >= 112
|
|
||||||
? "enter send · alt+enter newline · pgup/pgdn scroll · ctrl+o tools · ctrl+c stop"
|
|
||||||
: this.renderer.width >= 72
|
|
||||||
? "enter send · alt+enter newline · ctrl+c stop"
|
|
||||||
: this.renderer.width >= 48
|
|
||||||
? "enter send · alt+enter newline"
|
|
||||||
: ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setTurnModel(model: string, preset?: string | null): void {
|
private setTurnModel(model: string, preset?: string | null): void {
|
||||||
@@ -1342,9 +1377,14 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resizeComposer(): void {
|
private resizeComposer(): void {
|
||||||
const maxHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3)))
|
const verticalPadding = this.renderer.height >= 12 ? 1 : 0
|
||||||
this.composer.maxHeight = maxHeight
|
const maxContentHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3)))
|
||||||
this.composerFrame.maxHeight = maxHeight
|
this.composer.minHeight = 1
|
||||||
|
this.composer.maxHeight = maxContentHeight
|
||||||
|
this.composerFrame.paddingTop = verticalPadding
|
||||||
|
this.composerFrame.paddingBottom = verticalPadding
|
||||||
|
this.composerFrame.minHeight = 1 + verticalPadding * 2
|
||||||
|
this.composerFrame.maxHeight = maxContentHeight + verticalPadding * 2
|
||||||
}
|
}
|
||||||
|
|
||||||
private composerSurface(): RGBA {
|
private composerSurface(): RGBA {
|
||||||
@@ -1513,7 +1553,7 @@ export class NanobotTui {
|
|||||||
try {
|
try {
|
||||||
if (!this.client.forkChat) throw new Error("branching is unavailable")
|
if (!this.client.forkChat) throw new Error("branching is unavailable")
|
||||||
this.ready = false
|
this.ready = false
|
||||||
this.promptQueue.clear()
|
this.clearPromptQueue()
|
||||||
this.sessionMetadataId += 1
|
this.sessionMetadataId += 1
|
||||||
this.sessionTitle = `Fork · ${preview.slice(0, 48)}`
|
this.sessionTitle = `Fork · ${preview.slice(0, 48)}`
|
||||||
this.contextTokens = null
|
this.contextTokens = null
|
||||||
@@ -1596,7 +1636,7 @@ export class NanobotTui {
|
|||||||
this.closeSessions()
|
this.closeSessions()
|
||||||
try {
|
try {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
this.promptQueue.clear()
|
this.clearPromptQueue()
|
||||||
this.sessionMetadataId += 1
|
this.sessionMetadataId += 1
|
||||||
this.sessionTitle = sessionLabel(session)
|
this.sessionTitle = sessionLabel(session)
|
||||||
this.applySessionModel(session)
|
this.applySessionModel(session)
|
||||||
@@ -1628,7 +1668,7 @@ export class NanobotTui {
|
|||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
try {
|
try {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
this.promptQueue.clear()
|
this.clearPromptQueue()
|
||||||
this.sessionMetadataId += 1
|
this.sessionMetadataId += 1
|
||||||
this.sessionTitle = "New chat"
|
this.sessionTitle = "New chat"
|
||||||
this.sessionModelPreset = null
|
this.sessionModelPreset = null
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { contextualFooterHints, footerHints } from "./footer-hints"
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
accent: "#EF8E30",
|
||||||
|
danger: "#F87171",
|
||||||
|
muted: "#A1A1AA",
|
||||||
|
separator: "#71717A",
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("footerHints", () => {
|
||||||
|
test("separates normal and destructive shortcuts semantically", () => {
|
||||||
|
const result = footerHints([
|
||||||
|
{ key: "enter", label: "steer" },
|
||||||
|
{ key: "ctrl+c", label: "stop", tone: "danger" },
|
||||||
|
], theme)
|
||||||
|
|
||||||
|
expect(result.chunks.map(({ text }) => text).join("")).toBe("enter steer · ctrl+c stop")
|
||||||
|
expect(result.chunks[0]?.fg?.toInts().slice(0, 3)).toEqual([239, 142, 48])
|
||||||
|
expect(result.chunks[3]?.fg?.toInts().slice(0, 3)).toEqual([248, 113, 113])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("adapts the active-turn vocabulary to available width", () => {
|
||||||
|
const wide = contextualFooterHints("active", 100, theme)
|
||||||
|
const compact = contextualFooterHints("active", 72, theme)
|
||||||
|
|
||||||
|
expect(wide.chunks.map(({ text }) => text).join(""))
|
||||||
|
.toBe("enter steer · tab queue · alt+↑ edit · ctrl+c stop")
|
||||||
|
expect(compact.chunks.map(({ text }) => text).join(""))
|
||||||
|
.toBe("enter steer · tab queue · ctrl+c stop")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { RGBA, StyledText, TextAttributes, type TextChunk } from "@opentui/core"
|
||||||
|
|
||||||
|
export interface FooterHint {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
tone?: "normal" | "danger"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FooterHintTheme {
|
||||||
|
accent: string
|
||||||
|
danger: string
|
||||||
|
muted: string
|
||||||
|
separator: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FooterMode =
|
||||||
|
| "mention"
|
||||||
|
| "active"
|
||||||
|
| "branch"
|
||||||
|
| "command"
|
||||||
|
| "session"
|
||||||
|
| "context"
|
||||||
|
| "history"
|
||||||
|
| "ready"
|
||||||
|
|
||||||
|
export function contextualFooterHints(
|
||||||
|
mode: FooterMode,
|
||||||
|
width: number,
|
||||||
|
theme: FooterHintTheme,
|
||||||
|
): StyledText {
|
||||||
|
return footerHints(hintsFor(mode, width), theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Give shortcuts visual hierarchy without turning the footer into a toolbar. */
|
||||||
|
export function footerHints(hints: readonly FooterHint[], theme: FooterHintTheme): StyledText {
|
||||||
|
const chunks: TextChunk[] = []
|
||||||
|
hints.forEach((hint, index) => {
|
||||||
|
if (index) chunks.push(chunk(" · ", theme.separator))
|
||||||
|
const color = hint.tone === "danger" ? theme.danger : theme.accent
|
||||||
|
chunks.push(chunk(hint.key, color, true))
|
||||||
|
chunks.push(chunk(` ${hint.label}`, theme.muted))
|
||||||
|
})
|
||||||
|
return new StyledText(chunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hintsFor(mode: FooterMode, width: number): FooterHint[] {
|
||||||
|
if (mode === "mention") return width >= 64
|
||||||
|
? [hint("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")]
|
||||||
|
: [hint("enter", "insert"), hint("esc", "close")]
|
||||||
|
if (mode === "active") return width >= 96
|
||||||
|
? [hint("enter", "steer"), hint("tab", "queue"), hint("alt+↑", "edit"), stopHint()]
|
||||||
|
: width >= 64 ? [hint("enter", "steer"), hint("tab", "queue"), stopHint()] : []
|
||||||
|
if (mode === "branch") return width >= 64
|
||||||
|
? [hint("type", "filter"), hint("↑↓", "choose"), hint("enter", "branch"), hint("esc", "close")]
|
||||||
|
: [hint("enter", "branch"), hint("esc", "close")]
|
||||||
|
if (mode === "command") return width >= 72
|
||||||
|
? [hint("↑↓", "choose"), hint("tab", "complete"), hint("esc", "close")]
|
||||||
|
: [hint("tab", "complete"), hint("esc", "close")]
|
||||||
|
if (mode === "session") return width >= 64
|
||||||
|
? [hint("type", "filter"), hint("↑↓", "choose"), hint("enter", "open"), hint("esc", "close")]
|
||||||
|
: [hint("enter", "open"), hint("esc", "close")]
|
||||||
|
if (mode === "context") return [hint("esc", "close"), hint("pgup/pgdn", "scroll")]
|
||||||
|
if (mode === "history") return width >= 72
|
||||||
|
? [hint("ctrl+end", "latest"), hint("pgup/pgdn", "scroll")]
|
||||||
|
: width >= 48 ? [hint("ctrl+end", "latest")] : []
|
||||||
|
if (width >= 112) return [
|
||||||
|
hint("enter", "send"),
|
||||||
|
hint("ctrl+j", "newline"),
|
||||||
|
hint("pgup/pgdn", "scroll"),
|
||||||
|
hint("ctrl+o", "tools"),
|
||||||
|
stopHint(),
|
||||||
|
]
|
||||||
|
if (width >= 72) return [hint("enter", "send"), hint("ctrl+j", "newline"), stopHint()]
|
||||||
|
return width >= 48 ? [hint("enter", "send"), hint("ctrl+j", "newline")] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function hint(key: string, label: string): FooterHint {
|
||||||
|
return { key, label }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHint(): FooterHint {
|
||||||
|
return { key: "ctrl+c", label: "stop", tone: "danger" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(text: string, color: string, bold = false): TextChunk {
|
||||||
|
return {
|
||||||
|
__isChunk: true,
|
||||||
|
text,
|
||||||
|
fg: RGBA.fromHex(color),
|
||||||
|
attributes: bold ? TextAttributes.BOLD : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,13 @@ import { PromptQueue } from "./prompt-queue"
|
|||||||
const prompt = (content: string) => ({ content, options: {} })
|
const prompt = (content: string) => ({ content, options: {} })
|
||||||
|
|
||||||
describe("PromptQueue", () => {
|
describe("PromptQueue", () => {
|
||||||
test("promotes the newest armed prompt to steering", () => {
|
test("returns the latest follow-up for editing", () => {
|
||||||
const queue = new PromptQueue()
|
const queue = new PromptQueue()
|
||||||
queue.enqueue(prompt("next one"))
|
queue.enqueue(prompt("next one"))
|
||||||
queue.enqueue(prompt("steer now"))
|
queue.enqueue(prompt("steer now"))
|
||||||
|
|
||||||
expect(queue.takeSteering()?.content).toBe("steer now")
|
expect(queue.takeLast()?.content).toBe("steer now")
|
||||||
expect(queue.takeSteering()).toBeNull()
|
expect(queue.snapshot().map(({ content }) => content)).toEqual(["next one"])
|
||||||
expect(queue.takeFollowUp()?.content).toBe("next one")
|
expect(queue.takeFollowUp()?.content).toBe("next one")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+6
-10
@@ -5,10 +5,9 @@ export interface QueuedPrompt {
|
|||||||
options: MessageOptions
|
options: MessageOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Owns the difference between steering the active turn and starting the next one. */
|
/** Owns prompts that should start after the active turn finishes. */
|
||||||
export class PromptQueue {
|
export class PromptQueue {
|
||||||
private prompts: QueuedPrompt[] = []
|
private prompts: QueuedPrompt[] = []
|
||||||
private armed = false
|
|
||||||
|
|
||||||
get length(): number {
|
get length(): number {
|
||||||
return this.prompts.length
|
return this.prompts.length
|
||||||
@@ -16,30 +15,27 @@ export class PromptQueue {
|
|||||||
|
|
||||||
enqueue(prompt: QueuedPrompt): void {
|
enqueue(prompt: QueuedPrompt): void {
|
||||||
this.prompts.push(prompt)
|
this.prompts.push(prompt)
|
||||||
this.armed = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A second Enter immediately promotes the newest queued prompt to steering. */
|
takeLast(): QueuedPrompt | null {
|
||||||
takeSteering(): QueuedPrompt | null {
|
|
||||||
if (!this.armed) return null
|
|
||||||
this.armed = false
|
|
||||||
return this.prompts.pop() ?? null
|
return this.prompts.pop() ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
takeFollowUp(): QueuedPrompt | null {
|
takeFollowUp(): QueuedPrompt | null {
|
||||||
this.armed = false
|
|
||||||
return this.prompts.shift() ?? null
|
return this.prompts.shift() ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
snapshot(): readonly QueuedPrompt[] {
|
||||||
|
return this.prompts
|
||||||
|
}
|
||||||
|
|
||||||
restore(): QueuedPrompt[] {
|
restore(): QueuedPrompt[] {
|
||||||
const prompts = this.prompts
|
const prompts = this.prompts
|
||||||
this.prompts = []
|
this.prompts = []
|
||||||
this.armed = false
|
|
||||||
return prompts
|
return prompts
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.prompts = []
|
this.prompts = []
|
||||||
this.armed = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
|
||||||
|
|
||||||
|
import { QueuePreview } from "./queue-preview"
|
||||||
|
|
||||||
|
describe("QueuePreview", () => {
|
||||||
|
let setup: TestRendererSetup | undefined
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (setup && !setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||||
|
setup = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shows the latest follow-ups without becoming another card", async () => {
|
||||||
|
setup = await createTestRenderer({ width: 64, height: 12, screenMode: "alternate-screen" })
|
||||||
|
const preview = new QueuePreview(setup.renderer, {
|
||||||
|
accent: "#EF8E30",
|
||||||
|
muted: "#A1A1AA",
|
||||||
|
faint: "#71717A",
|
||||||
|
})
|
||||||
|
setup.renderer.root.add(preview.root)
|
||||||
|
|
||||||
|
preview.update(["first\nline", "second", "third", "fourth"])
|
||||||
|
await setup.renderOnce()
|
||||||
|
const frame = setup.captureCharFrame()
|
||||||
|
|
||||||
|
expect(frame).toContain("Queued next 4")
|
||||||
|
expect(frame).not.toContain("first line")
|
||||||
|
expect(frame).toContain("↳ second")
|
||||||
|
expect(frame).toContain("↳ fourth")
|
||||||
|
|
||||||
|
preview.update([])
|
||||||
|
await setup.renderOnce()
|
||||||
|
expect(setup.captureCharFrame()).not.toContain("Queued next")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
BoxRenderable,
|
||||||
|
RGBA,
|
||||||
|
StyledText,
|
||||||
|
TextAttributes,
|
||||||
|
TextRenderable,
|
||||||
|
type CliRenderer,
|
||||||
|
type TextChunk,
|
||||||
|
} from "@opentui/core"
|
||||||
|
|
||||||
|
export interface QueuePreviewTheme {
|
||||||
|
accent: string
|
||||||
|
muted: string
|
||||||
|
faint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_VISIBLE = 3
|
||||||
|
|
||||||
|
/** A compact, retained projection of follow-ups waiting behind the active turn. */
|
||||||
|
export class QueuePreview {
|
||||||
|
readonly root: BoxRenderable
|
||||||
|
private readonly header: TextRenderable
|
||||||
|
private readonly rows: TextRenderable[]
|
||||||
|
private theme: QueuePreviewTheme
|
||||||
|
private messages: readonly string[] = []
|
||||||
|
|
||||||
|
constructor(renderer: CliRenderer, theme: QueuePreviewTheme) {
|
||||||
|
this.theme = theme
|
||||||
|
this.root = new BoxRenderable(renderer, {
|
||||||
|
id: "nanobot-tui-queue-preview",
|
||||||
|
width: "100%",
|
||||||
|
height: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexDirection: "column",
|
||||||
|
paddingLeft: 1,
|
||||||
|
paddingRight: 1,
|
||||||
|
visible: false,
|
||||||
|
backgroundColor: RGBA.defaultBackground(),
|
||||||
|
})
|
||||||
|
this.header = new TextRenderable(renderer, {
|
||||||
|
id: "nanobot-tui-queue-header",
|
||||||
|
width: "100%",
|
||||||
|
height: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
truncate: true,
|
||||||
|
})
|
||||||
|
this.rows = Array.from({ length: MAX_VISIBLE }, (_, index) => new TextRenderable(renderer, {
|
||||||
|
id: `nanobot-tui-queue-row-${index}`,
|
||||||
|
width: "100%",
|
||||||
|
height: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
truncate: true,
|
||||||
|
visible: false,
|
||||||
|
}))
|
||||||
|
this.root.add(this.header)
|
||||||
|
for (const row of this.rows) this.root.add(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(messages: readonly string[]): void {
|
||||||
|
this.messages = [...messages]
|
||||||
|
const visible = messages.slice(-MAX_VISIBLE)
|
||||||
|
this.root.visible = messages.length > 0
|
||||||
|
this.root.height = messages.length ? visible.length + 1 : 1
|
||||||
|
this.header.content = new StyledText([
|
||||||
|
chunk("Queued next", this.theme.accent, true),
|
||||||
|
chunk(` ${messages.length}`, this.theme.faint),
|
||||||
|
chunk(" · alt+↑ edit last", this.theme.faint),
|
||||||
|
])
|
||||||
|
this.rows.forEach((row, index) => {
|
||||||
|
const message = visible[index]
|
||||||
|
row.visible = Boolean(message)
|
||||||
|
row.content = message ? ` ↳ ${oneLine(message)}` : ""
|
||||||
|
row.fg = this.theme.muted
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(theme: QueuePreviewTheme): void {
|
||||||
|
this.theme = theme
|
||||||
|
this.update(this.messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneLine(value: string): string {
|
||||||
|
const preview = value.slice(0, 240).replace(/\s+/gu, " ").trim()
|
||||||
|
return value.length > 240 ? `${preview}…` : preview
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(text: string, color: string, bold = false): TextChunk {
|
||||||
|
return {
|
||||||
|
__isChunk: true,
|
||||||
|
text,
|
||||||
|
fg: RGBA.fromHex(color),
|
||||||
|
attributes: bold ? TextAttributes.BOLD : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user