diff --git a/App/frontend/desktop/src/pages/tests/nickname-modal-flow.test.ts b/App/frontend/desktop/src/pages/tests/nickname-modal-flow.test.ts index 1d7e86c4..8753ff4d 100644 --- a/App/frontend/desktop/src/pages/tests/nickname-modal-flow.test.ts +++ b/App/frontend/desktop/src/pages/tests/nickname-modal-flow.test.ts @@ -14,7 +14,7 @@ describe("nickname modal flow", () => { for (const { path, continueFn } of accountRegistrationEntries) { const pageSource = readSource(path); - expect(pageSource).toContain(`await ${continueFn}(`); + expect(pageSource).toContain(`await ${continueFn}(onboardingPatch);`); // The new-user registration branch no longer opens the nickname modal during registration, to avoid pushing the nickname ahead of the scan authorization step. expect(pageSource).not.toContain('dispatch(appActions.modalChanged("nickname", true));'); } diff --git a/App/memmy-agent/src/config/schema.ts b/App/memmy-agent/src/config/schema.ts index 391351e1..06549f97 100644 --- a/App/memmy-agent/src/config/schema.ts +++ b/App/memmy-agent/src/config/schema.ts @@ -827,7 +827,7 @@ export function isValidImageGenerationMaxImagesPerTurn(value: unknown): value is } export class ImageGenerationToolConfig extends Base { - enabled = false; + enabled = true; provider = "openai"; model = "gpt-image-2"; apiKey = ""; @@ -851,7 +851,7 @@ export class ImageGenerationToolConfig extends Base { throw new ValueError(`tools.imageGeneration current contract does not accept legacy model field '${legacy}'`); } } - this.enabled = pick(init, ["enabled"], false); + this.enabled = pick(init, ["enabled"], true); this.profileMode = false; this.defaultAspectRatio = pick( init, diff --git a/App/memmy-agent/src/entrypoints/cli/tui-gateway-client.ts b/App/memmy-agent/src/entrypoints/cli/tui-gateway-client.ts index 41722afa..7b13a834 100644 --- a/App/memmy-agent/src/entrypoints/cli/tui-gateway-client.ts +++ b/App/memmy-agent/src/entrypoints/cli/tui-gateway-client.ts @@ -34,6 +34,26 @@ export type TuiGatewayMessage = { turnId: string | null; }; +export type TuiSlashCommand = { + command: string; + title: string; + description: string; + icon: string; + argHint: string; + source: "gateway" | "local"; +}; + +export type TuiSlashCommandsStatus = "loading" | "ready" | "stale" | "error"; + +export type TuiLastCompaction = { + available: boolean; + sessionKey: string; + mode: "text" | "dag" | null; + text: string; + lastActive: string | null; + dagSnapshotId?: string; +}; + export type TuiModelSelection = Readonly<{ presetId: string; provider: string; @@ -60,6 +80,9 @@ export type TuiGatewayState = { modelName: string | null; modelSelection: TuiModelSelection | null; toolNames: string[]; + slashCommands: TuiSlashCommand[]; + slashCommandsStatus: TuiSlashCommandsStatus; + slashCommandsError: string | null; notice: string; }; @@ -104,6 +127,22 @@ type PendingSubmission = { waiters: Set; }; +type SlashCatalogRequest = { + generation: number; + apiToken: string; + attempt: number; + controller: AbortController | null; + retryTimer: NodeJS.Timeout | null; + cancelled: boolean; +}; + +type LastCompactionRequest = { + id: symbol; + generation: number; + controller: AbortController; + promise: Promise; +}; + type BootstrapResponse = { token: string; ws_path: string; @@ -151,6 +190,9 @@ const WS_OPEN = 1; const DEFAULT_RECONNECT_DELAY_MS = 500; const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; const MAX_TUI_MESSAGES = 24; +const SLASH_CATALOG_RETRY_DELAYS_MS = [300, 1_000, 2_500] as const; +const SLASH_COMMAND_PATTERN = /^\/[a-z0-9-]+$/; +const SLASH_COMMANDS_ERROR = "Command catalog unavailable."; function deferred(): Deferred { let resolve!: (value: T) => void; @@ -261,6 +303,55 @@ function parseQueueItem(value: unknown): TuiGatewayQueueItem | null { return { clientRequestId, text: value.text, queuedAt, source }; } +function parseSlashCommands(value: unknown): TuiSlashCommand[] | null { + if (!isRecord(value) || !Array.isArray(value.commands)) return null; + const seen = new Set(); + const commands: TuiSlashCommand[] = []; + for (const item of value.commands) { + if (!isRecord(item)) continue; + const command = stringValue(item.command)?.toLowerCase() ?? null; + const title = stringValue(item.title); + const description = stringValue(item.description); + if (!command || !title || !description || !SLASH_COMMAND_PATTERN.test(command)) continue; + if (seen.has(command)) continue; + seen.add(command); + commands.push({ + command, + title, + description, + icon: typeof item.icon === "string" ? item.icon : "", + argHint: typeof item.arg_hint === "string" ? item.arg_hint : "", + source: "gateway", + }); + } + return commands.length > 0 ? commands : null; +} + +function parseLastCompaction(value: unknown, sessionKey: string): TuiLastCompaction | null { + if ( + !isRecord(value) + || typeof value.available !== "boolean" + || value.sessionKey !== sessionKey + || (value.mode !== "text" && value.mode !== "dag" && value.mode !== null) + || typeof value.text !== "string" + || (value.lastActive !== null && typeof value.lastActive !== "string") + || (value.dagSnapshotId !== undefined && typeof value.dagSnapshotId !== "string") + ) return null; + if (value.available) { + if (value.mode === null || !value.text.trim()) return null; + } else if (value.mode !== null) { + return null; + } + return { + available: value.available, + sessionKey, + mode: value.mode, + text: value.text, + lastActive: value.lastActive, + ...(value.dagSnapshotId === undefined ? {} : { dagSnapshotId: value.dagSnapshotId }), + }; +} + function normalizeHistoryMessage(value: unknown, index: number): TuiGatewayMessage | null { if (!isRecord(value)) return null; const rawRole = stringValue(value.role); @@ -348,6 +439,9 @@ export class TuiGatewayClient { private desyncedQueueRevision: number | null = null; private initialStart: Deferred | null = null; private stopRequest: Deferred | null = null; + private currentApiToken: string | null = null; + private slashCatalogRequest: SlashCatalogRequest | null = null; + private lastCompactionRequest: LastCompactionRequest | null = null; private state: TuiGatewayState = { connection: "closed", attached: false, @@ -363,6 +457,9 @@ export class TuiGatewayClient { modelName: null, modelSelection: null, toolNames: [], + slashCommands: [], + slashCommandsStatus: "loading", + slashCommandsError: null, notice: "connecting", }; @@ -400,6 +497,7 @@ export class TuiGatewayClient { if (this.closed) return; this.closed = true; this.generation += 1; + this.clearAuthorizedRequests(); if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = null; const socket = this.socket; @@ -422,6 +520,7 @@ export class TuiGatewayClient { ownedByTui: false, activeTurnId: null, startedAt: null, + slashCommandsStatus: this.state.slashCommands.length > 0 ? "stale" : "loading", notice: "closed", }); } @@ -491,6 +590,30 @@ export class TuiGatewayClient { return pending.promise; } + readLastCompaction(): Promise { + const generation = this.generation; + const apiToken = this.currentApiToken; + if ( + !apiToken + || !this.state.attached + || this.state.connection !== "connected" + ) { + return Promise.reject(new Error("Gateway is not attached to this Session")); + } + const current = this.lastCompactionRequest; + if (current && current.generation === generation) return current.promise; + + const controller = new AbortController(); + const id = Symbol("last-compaction-request"); + const promise = this.fetchLastCompaction(generation, apiToken, controller) + .finally(() => { + if (this.lastCompactionRequest?.id === id) this.lastCompactionRequest = null; + }); + const request: LastCompactionRequest = { id, generation, controller, promise }; + this.lastCompactionRequest = request; + return promise; + } + private patch(patch: Partial): void { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) listener(this.state); @@ -498,9 +621,15 @@ export class TuiGatewayClient { private async connect(initial: boolean): Promise { const generation = ++this.generation; + this.clearAuthorizedRequests(); + this.patch({ + slashCommandsStatus: this.state.slashCommands.length > 0 ? "stale" : "loading", + slashCommandsError: null, + }); try { const bootstrap = await this.bootstrap(); if (this.closed || generation !== this.generation) return; + this.currentApiToken = bootstrap.token; this.patch({ modelName: bootstrap.model_name, modelSelection: bootstrap.model_selection, @@ -525,6 +654,7 @@ export class TuiGatewayClient { this.initialStart.reject(unavailable); this.initialStart = null; this.closed = true; + this.clearAuthorizedRequests(); this.patch({ connection: "closed", notice: unavailable.message }); return; } @@ -592,6 +722,7 @@ export class TuiGatewayClient { }); this.historyBuffers.set(generation, []); void this.hydrateHistory(generation, apiToken); + this.hydrateSlashCommands(generation, apiToken); return; } if (!this.state.attached) return; @@ -754,6 +885,127 @@ export class TuiGatewayClient { } } + private hydrateSlashCommands(generation: number, apiToken: string): void { + this.cancelSlashCatalogRequest(); + const request: SlashCatalogRequest = { + generation, + apiToken, + attempt: 0, + controller: null, + retryTimer: null, + cancelled: false, + }; + this.slashCatalogRequest = request; + void this.fetchSlashCommands(request); + } + + private async fetchSlashCommands(request: SlashCatalogRequest): Promise { + if (!this.isSlashCatalogRequestCurrent(request)) return; + const controller = new AbortController(); + request.controller = controller; + const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); + let commands: TuiSlashCommand[] | null = null; + try { + const response = await this.fetchImpl(`${this.baseUrl}/api/commands`, { + headers: { authorization: `Bearer ${request.apiToken}` }, + signal: controller.signal, + }); + if (!response.ok) throw new Error("command catalog request failed"); + commands = parseSlashCommands(await response.json()); + if (!commands) throw new Error("command catalog response is invalid"); + } catch { + commands = null; + } finally { + clearTimeout(timeout); + if (request.controller === controller) request.controller = null; + } + if (!this.isSlashCatalogRequestCurrent(request)) return; + if (commands) { + this.slashCatalogRequest = null; + this.patch({ + slashCommands: commands, + slashCommandsStatus: "ready", + slashCommandsError: null, + }); + return; + } + if (request.attempt < SLASH_CATALOG_RETRY_DELAYS_MS.length) { + const delay = SLASH_CATALOG_RETRY_DELAYS_MS[request.attempt]!; + request.attempt += 1; + request.retryTimer = setTimeout(() => { + request.retryTimer = null; + void this.fetchSlashCommands(request); + }, delay); + return; + } + this.slashCatalogRequest = null; + this.patch({ + slashCommandsStatus: this.state.slashCommands.length > 0 ? "stale" : "error", + slashCommandsError: SLASH_COMMANDS_ERROR, + }); + } + + private async fetchLastCompaction( + generation: number, + apiToken: string, + controller: AbortController, + ): Promise { + const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs); + const sessionKey = `websocket:${this.chatId}`; + const key = encodeURIComponent(sessionKey); + try { + const response = await this.fetchImpl( + `${this.baseUrl}/api/sessions/${key}/last-compaction`, + { + headers: { authorization: `Bearer ${apiToken}` }, + signal: controller.signal, + }, + ); + if (!this.isAuthorizedGenerationCurrent(generation, apiToken)) { + throw new Error("Gateway connection changed"); + } + if (!response.ok) throw new Error("Last compaction request failed"); + const parsed = parseLastCompaction(await response.json(), sessionKey); + if (!parsed) throw new Error("Last compaction response is invalid"); + if (!this.isAuthorizedGenerationCurrent(generation, apiToken)) { + throw new Error("Gateway connection changed"); + } + return parsed; + } finally { + clearTimeout(timer); + } + } + + private isSlashCatalogRequestCurrent(request: SlashCatalogRequest): boolean { + return !request.cancelled + && this.slashCatalogRequest === request + && this.isGenerationCurrent(request.generation) + && this.currentApiToken === request.apiToken; + } + + private isAuthorizedGenerationCurrent(generation: number, apiToken: string): boolean { + return this.isGenerationCurrent(generation) + && this.currentApiToken === apiToken + && this.state.attached + && this.state.connection === "connected"; + } + + private cancelSlashCatalogRequest(): void { + const request = this.slashCatalogRequest; + if (!request) return; + request.cancelled = true; + request.controller?.abort(); + if (request.retryTimer) clearTimeout(request.retryTimer); + this.slashCatalogRequest = null; + } + + private clearAuthorizedRequests(): void { + this.currentApiToken = null; + this.cancelSlashCatalogRequest(); + this.lastCompactionRequest?.controller.abort(); + this.lastCompactionRequest = null; + } + private async hydrateHistory(generation: number, apiToken: string): Promise { try { const key = encodeURIComponent(`websocket:${this.chatId}`); @@ -790,10 +1042,12 @@ export class TuiGatewayClient { startup.reject(unavailable); this.initialStart = null; this.closed = true; + this.clearAuthorizedRequests(); this.socket?.close(1011, "history unavailable"); this.patch({ connection: "closed", attached: false, notice: unavailable.message }); return; } + this.clearAuthorizedRequests(); this.socket?.close(1011, "history unavailable"); } } @@ -1028,6 +1282,7 @@ export class TuiGatewayClient { if (!this.isCurrent(socket, generation)) return; this.socket = null; this.historyBuffers.delete(generation); + this.clearAuthorizedRequests(); if (this.closed) return; for (const attempt of this.pendingSubmissions.values()) attempt.sentGeneration = null; this.acceptedModelUpdateRequests.clear(); @@ -1042,6 +1297,8 @@ export class TuiGatewayClient { activeTurnId: null, startedAt: null, goalState: null, + slashCommandsStatus: this.state.slashCommands.length > 0 ? "stale" : "loading", + slashCommandsError: null, notice: "Gateway disconnected; reconnecting", }); this.desyncedQueueRevision = null; diff --git a/App/memmy-agent/src/entrypoints/cli/tui-slash-menu.tsx b/App/memmy-agent/src/entrypoints/cli/tui-slash-menu.tsx new file mode 100644 index 00000000..77f3888f --- /dev/null +++ b/App/memmy-agent/src/entrypoints/cli/tui-slash-menu.tsx @@ -0,0 +1,248 @@ +import { Box, Text } from "ink"; +import React, { useMemo } from "react"; +import stringWidth from "string-width"; +import type { + TuiGatewayState, + TuiSlashCommand, + TuiSlashCommandsStatus, +} from "./tui-gateway-client.js"; + +export type TuiInputClassification = + | "gateway" + | "local-last-compaction" + | "local-quit" + | "local-stop"; + +type SlashCommandAvailability = Pick< + TuiGatewayState, + "activeTurnId" | "attached" | "busy" | "connection" | "ownedByTui" +>; + +const MAX_SLASH_RESULTS = 8; +const PRIMARY_ORDER = [ + "/stop", + "/new", + "/status", + "/model", + "/history", + "/last-compaction", + "/goal", + "/help", + "/quit", + "/history-dag", + "/dream", + "/dream-log", + "/dream-restore", + "/pairing", +] as const; +const PRIMARY_RANK = new Map( + PRIMARY_ORDER.map((command, index) => [command, index]), +); +const RESERVED_LOCAL_COMMANDS = new Set(["/stop", "/last-compaction", "/quit"]); +const EXIT_ALIASES = new Set(["/quit", "/exit", "exit", "quit", ":q"]); +const SLASH_DRAFT_PATTERN = /^\/[A-Za-z0-9_-]*$/; + +const LOCAL_STOP: TuiSlashCommand = { + command: "/stop", + title: "Stop current TUI Turn", + description: "Stop only the active Turn owned by this TUI.", + icon: "square", + argHint: "", + source: "local", +}; + +const LOCAL_LAST_COMPACTION: TuiSlashCommand = { + command: "/last-compaction", + title: "Show last compaction", + description: "Show the latest saved compaction summary for this Session.", + icon: "book-open", + argHint: "", + source: "local", +}; + +const LOCAL_QUIT: TuiSlashCommand = { + command: "/quit", + title: "Exit TUI", + description: "Disconnect and exit without stopping the active Turn.", + icon: "log-out", + argHint: "", + source: "local", +}; + +export function classifyTuiInput(text: string): TuiInputClassification { + const normalized = text.trim().toLowerCase(); + if (normalized === "/stop") return "local-stop"; + if (normalized === "/last-compaction") return "local-last-compaction"; + if (EXIT_ALIASES.has(normalized)) return "local-quit"; + return "gateway"; +} + +export function isTuiSlashDraft(draft: string): boolean { + return SLASH_DRAFT_PATTERN.test(draft); +} + +export function isTuiSlashMenuOpen(draft: string, dismissedDraft: string | null): boolean { + return isTuiSlashDraft(draft) && dismissedDraft !== draft; +} + +export function buildTuiSlashCommands( + gatewayCommands: readonly TuiSlashCommand[], + gatewayState: SlashCommandAvailability, +): TuiSlashCommand[] { + const commands = gatewayCommands.filter((command) => { + if (command.command === "/restart" || RESERVED_LOCAL_COMMANDS.has(command.command)) + return false; + return !(command.command === "/new" && gatewayState.busy && !gatewayState.ownedByTui); + }); + if ( + gatewayState.connection === "connected" && + gatewayState.attached && + gatewayState.busy && + gatewayState.ownedByTui && + gatewayState.activeTurnId + ) { + commands.push(LOCAL_STOP); + } + commands.push(LOCAL_LAST_COMPACTION, LOCAL_QUIT); + + return commands + .map((command, serviceIndex) => ({ command, serviceIndex })) + .sort((left, right) => { + const leftRank = PRIMARY_RANK.get(left.command.command); + const rightRank = PRIMARY_RANK.get(right.command.command); + if (leftRank !== undefined && rightRank !== undefined) return leftRank - rightRank; + if (leftRank !== undefined) return -1; + if (rightRank !== undefined) return 1; + return left.serviceIndex - right.serviceIndex; + }) + .map(({ command }) => command); +} + +export function queryTuiSlashCommands( + commands: readonly TuiSlashCommand[], + draft: string, +): TuiSlashCommand[] { + if (!isTuiSlashDraft(draft)) return []; + const query = draft.slice(1).toLowerCase(); + if (!query) return commands.slice(0, MAX_SLASH_RESULTS); + + return commands + .map((command, index) => { + const commandKey = command.command.slice(1).toLowerCase(); + const searchable = [commandKey, command.title, command.description, command.argHint] + .join("\n") + .toLowerCase(); + const rank = + commandKey === query + ? 0 + : commandKey.startsWith(query) + ? 1 + : searchable.includes(query) + ? 2 + : null; + return { command, index, rank }; + }) + .filter( + (item): item is { command: TuiSlashCommand; index: number; rank: number } => + item.rank !== null, + ) + .sort((left, right) => left.rank - right.rank || left.index - right.index) + .slice(0, MAX_SLASH_RESULTS) + .map(({ command }) => command); +} + +export function completeTuiSlashCommand(command: TuiSlashCommand): string { + return command.argHint ? `${command.command} ` : command.command; +} + +export function slashMenuStatusText(status: TuiSlashCommandsStatus): string { + if (status === "loading") return "Loading Gateway commands..."; + if (status === "error") { + return "Gateway commands unavailable; local commands remain available."; + } + return "No matching command."; +} + +function truncateLine(value: string, width: number): string { + if (width <= 0) return ""; + if (stringWidth(value) <= width) return value; + if (width <= 3) return ".".repeat(width); + let output = ""; + for (const character of value) { + if (stringWidth(output + character) > width - 3) break; + output += character; + } + return `${output}...`; +} + +export function formatTuiSlashMenuRows( + commands: readonly TuiSlashCommand[], + selectedIndex: number, + columns: number, +): string[] { + if (commands.length === 0) return []; + const contentWidth = Math.max(1, columns - 4); + const leftValues = commands.map((command) => + command.argHint ? `${command.command} ${command.argHint}` : command.command, + ); + const leftCap = Math.max(1, Math.floor(contentWidth * 0.6)); + const leftWidth = Math.max( + 1, + Math.min(leftCap, Math.max(...leftValues.map((value) => stringWidth(value)))), + ); + const rightWidth = contentWidth - leftWidth - 2; + + return commands.map((command, index) => { + const prefix = index === selectedIndex ? "› " : " "; + const left = truncateLine(leftValues[index]!, leftWidth); + if (rightWidth < 3) return `${prefix}${truncateLine(left, contentWidth)}`; + const padding = " ".repeat(Math.max(0, leftWidth - stringWidth(left))); + return `${prefix}${left}${padding} ${truncateLine(command.title, rightWidth)}`; + }); +} + +export function tuiSlashMenuRowCount(open: boolean, candidateCount: number): number { + return open ? Math.max(1, candidateCount) : 0; +} + +export function tuiVisibleMessageCount(menuRowCount: number): number { + return Math.max(0, 8 - menuRowCount); +} + +export function SlashMenu({ + columns, + commands, + selectedIndex, + status, +}: { + columns: number; + commands: readonly TuiSlashCommand[]; + selectedIndex: number; + status: TuiSlashCommandsStatus; +}) { + const rows = useMemo( + () => formatTuiSlashMenuRows(commands, selectedIndex, columns), + [columns, commands, selectedIndex], + ); + + return ( + + {rows.length > 0 ? ( + rows.map((row, index) => ( + + {row} + + )) + ) : ( + + {slashMenuStatusText(status)} + + )} + + ); +} diff --git a/App/memmy-agent/src/entrypoints/cli/tui.tsx b/App/memmy-agent/src/entrypoints/cli/tui.tsx index 230eb88c..d46d2052 100644 --- a/App/memmy-agent/src/entrypoints/cli/tui.tsx +++ b/App/memmy-agent/src/entrypoints/cli/tui.tsx @@ -16,6 +16,17 @@ import { type TuiModelSelection, } from "./tui-gateway-client.js"; import { resolveComposerCursorPosition, type ComposerLayout } from "./tui-cursor.js"; +import { + buildTuiSlashCommands, + classifyTuiInput, + completeTuiSlashCommand, + isTuiSlashMenuOpen, + queryTuiSlashCommands, + SlashMenu, + slashMenuStatusText, + tuiSlashMenuRowCount, + tuiVisibleMessageCount, +} from "./tui-slash-menu.js"; type TuiMessageRole = "assistant" | "progress" | "system" | "user"; @@ -962,10 +973,17 @@ function MemmyTui({ config, gateway, registerCleanup, target, toolsets, version clientRequestId: string; text: string; } | null>(null); + const lastCompactionPendingRef = useRef< + ReturnType | null + >(null); const [localMessages, setLocalMessages] = useState(() => []); const [gatewayState, setGatewayState] = useState(() => gateway.snapshot()); + const gatewayStateRef = useRef(gatewayState); const [notice, setNotice] = useState(""); const [now, setNow] = useState(() => Date.now()); + const [slashSelectedIndex, setSlashSelectedIndex] = useState(0); + const [dismissedSlashDraft, setDismissedSlashDraft] = useState(null); + gatewayStateRef.current = gatewayState; const appendMessage = useCallback((role: TuiMessageRole, text: string) => { setLocalMessages((prev) => [...prev, { id: idRef.current++, role, text }].slice(-MAX_MESSAGES)); @@ -980,6 +998,21 @@ function MemmyTui({ config, gateway, registerCleanup, target, toolsets, version setInputCursor(safeCursor); }, []); + const slashCommands = useMemo( + () => buildTuiSlashCommands(gatewayState.slashCommands, gatewayState), + [gatewayState], + ); + const slashMenuOpen = isTuiSlashMenuOpen(input, dismissedSlashDraft); + const slashCandidates = useMemo( + () => slashMenuOpen ? queryTuiSlashCommands(slashCommands, input) : [], + [input, slashCommands, slashMenuOpen], + ); + const slashCandidateKey = slashCandidates.map((command) => command.command).join("\n"); + + useEffect(() => { + setSlashSelectedIndex(0); + }, [slashCandidateKey]); + useEffect(() => { const unsubscribe = gateway.subscribe(setGatewayState); const cleanup = onceCleanup(async () => { @@ -1003,11 +1036,6 @@ function MemmyTui({ config, gateway, registerCleanup, target, toolsets, version (value: string, turnAdmission: "queue" | "steer") => { const text = value.trim(); if (!text) return; - if (["exit", "quit", "/exit", "/quit", ":q"].includes(text.toLowerCase())) { - appendMessage("system", "Goodbye."); - exit(); - return; - } if (turnAdmission === "steer" && !gatewayState.ownedByTui) { setNotice("The current Turn belongs to another channel; use Enter to queue."); return; @@ -1035,9 +1063,83 @@ function MemmyTui({ config, gateway, registerCleanup, target, toolsets, version setNotice(`Error: ${error instanceof Error ? error.message : String(error)}`); }); }, - [appendMessage, exit, gateway, gatewayState.ownedByTui, setDraft], + [gateway, gatewayState.ownedByTui, setDraft], ); + const dispatchTuiInput = useCallback((value: string) => { + const action = classifyTuiInput(value); + if (action === "local-quit") { + appendMessage("system", "Goodbye."); + exit(); + return; + } + if (action === "local-stop") { + const latest = gatewayStateRef.current; + if ( + latest.connection !== "connected" + || !latest.attached + || !latest.busy + || !latest.ownedByTui + || !latest.activeTurnId + ) { + setNotice("No TUI-owned Turn is running."); + return; + } + setNotice("stopping current TUI Turn"); + void gateway.stopOwnedTurn() + .then((outcome) => { + if (outcome === "not_owned") { + setNotice("Could not stop the current TUI Turn."); + return; + } + if (classifyTuiInput(inputRef.current) === "local-stop") setDraft("", 0); + appendMessage( + "system", + outcome === "stopped" + ? "Stopped the current TUI Turn." + : "The current TUI Turn already finished.", + ); + setNotice("ready"); + }) + .catch(() => setNotice("Could not stop the current TUI Turn.")); + return; + } + if (action === "local-last-compaction") { + if (lastCompactionPendingRef.current) { + setNotice("Reading the last compaction summary..."); + return; + } + setNotice("Reading the last compaction summary..."); + const requestDraft = inputRef.current; + const pending = gateway.readLastCompaction(); + lastCompactionPendingRef.current = pending; + void pending + .then((result) => { + appendMessage( + "system", + result.available + ? result.text + : "No compaction summary is available for this Session.", + ); + if ( + inputRef.current === requestDraft + && classifyTuiInput(inputRef.current) === "local-last-compaction" + ) { + setDraft("", 0); + } + setNotice("ready"); + }) + .catch(() => setNotice("Could not read the last compaction summary.")) + .finally(() => { + if (lastCompactionPendingRef.current === pending) { + lastCompactionPendingRef.current = null; + } + }); + return; + } + submit(value, "queue"); + }, [appendMessage, exit, gateway, setDraft, submit]); + useInput((value, key) => { if (key.ctrl && value === "c") { if (gatewayState.busy && gatewayState.ownedByTui) { @@ -1053,8 +1155,44 @@ function MemmyTui({ config, gateway, registerCleanup, target, toolsets, version return; } + if (slashMenuOpen && key.escape) { + setDismissedSlashDraft(inputRef.current); + return; + } + + if (slashMenuOpen && (key.upArrow || key.downArrow)) { + if (slashCandidates.length > 0) { + setSlashSelectedIndex((current) => { + const direction = key.upArrow ? -1 : 1; + return (current + direction + slashCandidates.length) % slashCandidates.length; + }); + } + return; + } + + const slashInput = inputRef.current.trimStart().startsWith("/"); + const selectedSlashCommand = slashCandidates[slashSelectedIndex] ?? slashCandidates[0]; + if (key.tab && slashInput) { + if (slashMenuOpen && selectedSlashCommand) { + const completed = completeTuiSlashCommand(selectedSlashCommand); + setDraft(completed, completed.length); + } else { + setNotice(slashMenuStatusText(gatewayState.slashCommandsStatus)); + } + return; + } + if (key.return) { - submit(inputRef.current, "queue"); + if ( + slashMenuOpen + && selectedSlashCommand + && inputRef.current.toLowerCase() !== selectedSlashCommand.command.toLowerCase() + ) { + const completed = completeTuiSlashCommand(selectedSlashCommand); + setDraft(completed, completed.length); + return; + } + dispatchTuiInput(inputRef.current); return; } @@ -1140,7 +1278,7 @@ function MemmyTui({ config, gateway, registerCleanup, target, toolsets, version const text = value.replace(/\r/g, ""); if (!text) return; if (text.includes("\n")) { - submit(currentInput.slice(0, currentCursor) + text.replace(/\n.*$/s, ""), "queue"); + dispatchTuiInput(currentInput.slice(0, currentCursor) + text.replace(/\n.*$/s, "")); return; } @@ -1156,7 +1294,11 @@ function MemmyTui({ config, gateway, registerCleanup, target, toolsets, version })), ...localMessages, ].slice(-MAX_MESSAGES); - const visibleMessages = messages.slice(-8); + const slashMenuRows = tuiSlashMenuRowCount(slashMenuOpen, slashCandidates.length); + const visibleMessageCount = tuiVisibleMessageCount(slashMenuRows); + const visibleMessages = visibleMessageCount === 0 + ? [] + : messages.slice(-visibleMessageCount); const elapsedMs = gatewayState.startedAt ? now - gatewayState.startedAt : 0; const ruleWidth = Math.max(0, columns - 2); const inputPlaceholder = gatewayState.busy @@ -1174,7 +1316,7 @@ function MemmyTui({ config, gateway, registerCleanup, target, toolsets, version - {messages.length ? ( + {visibleMessages.length ? ( {visibleMessages.map((message) => ( @@ -1198,6 +1340,15 @@ function MemmyTui({ config, gateway, registerCleanup, target, toolsets, version notice={notice || gatewayState.notice} /> + {slashMenuOpen ? ( + + ) : null} + { const config = new ImageGenerationToolConfig(); expect(config.toObject()).toEqual({ - enabled: false, + enabled: true, defaultAspectRatio: "1:1", defaultImageSize: "1K", maxImagesPerTurn: null, @@ -33,6 +33,13 @@ describe("ImageGenerationToolConfig", () => { }); }); + it("honors an explicit disable", () => { + const config = new ImageGenerationToolConfig({ enabled: false }); + + expect(config.enabled).toBe(false); + expect(config.toObject().enabled).toBe(false); + }); + it("accepts null and positive safe integer turn limits", () => { expect(new ImageGenerationToolConfig({ maxImagesPerTurn: null }).maxImagesPerTurn).toBeNull(); for (const value of [1, 24, 1000, Number.MAX_SAFE_INTEGER]) { diff --git a/App/memmy-agent/tests/core/agent-runtime/loop-goal-continuation.test.ts b/App/memmy-agent/tests/core/agent-runtime/loop-goal-continuation.test.ts index eec0160a..ca239740 100644 --- a/App/memmy-agent/tests/core/agent-runtime/loop-goal-continuation.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/loop-goal-continuation.test.ts @@ -160,6 +160,53 @@ describe("Goal continuation scheduling", () => { expect(scheduleGoalWork).not.toHaveBeenCalled(); }); + it("keeps an active Goal active when its exact Turn is cancelled", async () => { + const loop = makeLoop(); + const goal = await createGoal(loop); + loop.goalRuntime.releaseTurn(SESSION_KEY, "turn-create"); + const scheduleGoalWork = vi.spyOn(loop, "scheduleGoalWork"); + const pauseAndCancel = vi.spyOn(loop.goalRuntime, "pauseAndCancel"); + const controller = new AbortController(); + let notifyRunnerStarted!: () => void; + const runnerStarted = new Promise((resolve) => { + notifyRunnerStarted = resolve; + }); + loop.runner.run = vi.fn(async (spec: AgentRunSpec) => { + notifyRunnerStarted(); + await new Promise((resolve) => { + spec.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); + }); + return new AgentRunResult({ + finalContent: "cancelled", + messages: spec.messages, + stopReason: "cancelled", + usage: { total_tokens: 7 }, + }); + }); + + const processing = loop.processMessage(new InboundMessage({ + channel: "websocket", + chatId: "goal-chat", + senderId: "user", + content: "continue the active Goal", + metadata: { webui: true }, + }), SESSION_KEY, { + abortSignal: controller.signal, + turnId: "turn-targeted-cancel", + }); + await runnerStarted; + controller.abort(); + + await expect(processing).rejects.toMatchObject({ name: "TaskCancelledError" }); + expect(loop.goalRuntime.get(SESSION_KEY)).toMatchObject({ + goalId: goal.goalId, + status: "active", + tokensUsed: 7, + }); + expect(pauseAndCancel).not.toHaveBeenCalled(); + expect(scheduleGoalWork).not.toHaveBeenCalled(); + }); + it("runs maxIterations continuations as distinct top-level Turns and stops on completed", async () => { const loop = makeLoop(); const goal = await createGoal(loop); diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/image-generation-tool.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/image-generation-tool.test.ts index 4b30d9c1..7e70bca0 100644 --- a/App/memmy-agent/tests/core/agent-runtime/tools/image-generation-tool.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/tools/image-generation-tool.test.ts @@ -144,7 +144,8 @@ describe("ImageGenerationTool", () => { }); it("enables only when the image capability assignment resolves", () => { - const config = new ImageGenerationToolConfig({ enabled: true }); + const config = new ImageGenerationToolConfig(); + expect(config.enabled).toBe(true); const selection = assignedImageSelection({ provider: "memmy_account", model: "image_gen", diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/tool-loader.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/tool-loader.test.ts index a52bff31..1f04507b 100644 --- a/App/memmy-agent/tests/core/agent-runtime/tools/tool-loader.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/tools/tool-loader.test.ts @@ -375,7 +375,7 @@ describe("Config round-trip", () => { expect(config.tools.exec.timeout).toBe(60); expect(config.tools.web.enable).toBe(true); expect(config.tools.web.search.provider).toBe("duckduckgo"); - expect(config.tools.imageGeneration.enabled).toBe(false); + expect(config.tools.imageGeneration.enabled).toBe(true); expect(config.tools.restrictToWorkspace).toBe(false); expect(["cli", "Apps"].join("") in config.tools).toBe(false); }); diff --git a/App/memmy-agent/tests/entrypoints/cli/tui-gateway-client.test.ts b/App/memmy-agent/tests/entrypoints/cli/tui-gateway-client.test.ts index 3af68f86..b0b481e2 100644 --- a/App/memmy-agent/tests/entrypoints/cli/tui-gateway-client.test.ts +++ b/App/memmy-agent/tests/entrypoints/cli/tui-gateway-client.test.ts @@ -69,6 +69,42 @@ const sessionSelection = { model: "gpt-fast", }; +const gatewayCommands = [{ + command: "/model", + title: "Switch model preset", + description: "Show, list, or switch the active model preset.", + icon: "brain", + arg_hint: "[list|preset]", +}, { + command: "/history", + title: "Show conversation history", + description: "Print the last N persisted conversation messages.", + icon: "history", + arg_hint: "[n]", +}, { + command: "/MODEL", + title: "Duplicate model", + description: "This duplicate must be ignored.", + icon: "duplicate", + arg_hint: "", +}, { + command: "not-a-slash-command", + title: "Invalid", + description: "This invalid entry must be ignored.", + icon: "invalid", + arg_hint: "", +}]; + +function commandWire(command: string): Record { + return { + command, + title: `${command} title`, + description: `${command} description`, + icon: "test", + arg_hint: "", + }; +} + async function waitUntil(predicate: () => boolean, timeoutMs = 1_000): Promise { const deadline = Date.now() + timeoutMs; while (!predicate() && Date.now() < deadline) { @@ -84,7 +120,7 @@ async function connectClient({ } = {}) { const sockets: FakeSocket[] = []; let bootstrapCount = 0; - const fetchImpl = vi.fn(async (input: string | URL) => { + const fetchImpl = vi.fn(async (input: string | URL, _init?: RequestInit) => { const url = String(input); if (url.endsWith("/webui/bootstrap")) { bootstrapCount += 1; @@ -98,8 +134,13 @@ async function connectClient({ }); } if (url.includes("/webui-thread?surface=tui")) { - return response({ schemaVersion: 3, sessionKey: "websocket:ext_Y2xpOnRlc3Q", messages: historyMessages }); + return response({ + schemaVersion: 3, + sessionKey: "websocket:ext_Y2xpOnRlc3Q", + messages: historyMessages, + }); } + if (url.endsWith("/api/commands")) return response({ commands: gatewayCommands }); throw new Error(`unexpected URL: ${url}`); }); const client = new TuiGatewayClient({ @@ -134,6 +175,7 @@ async function connectClient({ started_items: [], }); await starting; + await waitUntil(() => client.snapshot().slashCommandsStatus === "ready"); return { client, fetchImpl, sockets }; } @@ -146,10 +188,11 @@ describe("TuiGatewayClient", () => { ], }); - expect(fetchImpl.mock.calls.map(([url]) => String(url))).toEqual([ + expect(fetchImpl.mock.calls.map(([url]) => String(url))).toEqual(expect.arrayContaining([ "http://127.0.0.1:18980/webui/bootstrap", expect.stringContaining("/webui-thread?surface=tui"), - ]); + "http://127.0.0.1:18980/api/commands", + ])); expect(client.snapshot()).toMatchObject({ connection: "connected", attached: true, @@ -162,6 +205,16 @@ describe("TuiGatewayClient", () => { model: "gpt-fast", }, toolNames: ["exec", "read_file"], + slashCommandsStatus: "ready", + slashCommands: [ + expect.objectContaining({ command: "/model", argHint: "[list|preset]", source: "gateway" }), + expect.objectContaining({ command: "/history", argHint: "[n]", source: "gateway" }), + ], + }); + const commandCall = fetchImpl.mock.calls.find(([url]) => String(url).endsWith("/api/commands")); + expect(commandCall?.[1]).toMatchObject({ + headers: { authorization: "Bearer token-1" }, + signal: expect.any(AbortSignal), }); expect(client.snapshot().messages.map((message) => message.text)).toEqual(["from TUI", "answer"]); expect(sockets).toHaveLength(1); @@ -178,6 +231,9 @@ describe("TuiGatewayClient", () => { if (String(input).endsWith("/webui/bootstrap")) { return response({ token: "token", ws_path: "/", expires_in: 300, model_name: null }); } + if (String(input).endsWith("/api/commands")) { + return response({ commands: gatewayCommands }); + } return history; }); const client = new TuiGatewayClient({ @@ -593,4 +649,215 @@ describe("TuiGatewayClient", () => { }); client.close(); }); + + it("keeps chat attached when the command catalog exhausts its retries", async () => { + const sockets: FakeSocket[] = []; + let catalogAttempt = 0; + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith("/webui/bootstrap")) { + return response({ + token: "catalog-token", + ws_path: "/gateway", + expires_in: 300, + model_name: null, + }); + } + if (url.includes("/webui-thread?surface=tui")) { + return response({ + schemaVersion: 3, + sessionKey: "websocket:ext_Y2xpOnRlc3Q", + messages: [], + }); + } + if (url.endsWith("/api/commands")) { + catalogAttempt += 1; + if (catalogAttempt === 1) return response({ error: "private 401 body" }, 401); + if (catalogAttempt === 2) return response({ error: "private 500 body" }, 500); + if (catalogAttempt === 3) return new Response("{", { status: 200 }); + return response({ commands: [] }); + } + throw new Error(`unexpected URL: ${url}`); + }); + const client = new TuiGatewayClient({ + baseUrl: "http://127.0.0.1:18980", + sessionKey: "cli:test", + fetchImpl, + webSocketFactory: () => { + const socket = new FakeSocket(); + sockets.push(socket); + return socket as unknown as TuiWebSocket; + }, + requestTimeoutMs: 500, + }); + const starting = client.start(); + await waitUntil(() => sockets.length === 1); + sockets[0]!.open(); + sockets[0]!.message({ event: "ready" }); + sockets[0]!.message({ event: "attached", chat_id: client.chatId }); + sockets[0]!.message({ + event: "message_queue_snapshot", + chat_id: client.chatId, + revision: 0, + items: [], + started_items: [], + }); + await starting; + await waitUntil(() => client.snapshot().slashCommandsStatus === "error", 5_000); + expect(client.snapshot()).toMatchObject({ + connection: "connected", + attached: true, + slashCommands: [], + slashCommandsStatus: "error", + slashCommandsError: "Command catalog unavailable.", + }); + expect(fetchImpl.mock.calls.filter(([url]) => String(url).endsWith("/api/commands"))) + .toHaveLength(4); + client.close(); + }, 7_000); + + it("keeps last-good commands and ignores a superseded generation response", async () => { + const { client, fetchImpl, sockets } = await connectClient(); + const originalFetch = fetchImpl.getMockImplementation(); + if (!originalFetch) throw new Error("fetch mock is missing its implementation"); + let resolveOldCatalog!: (value: Response) => void; + const oldCatalogCapture: { signal?: AbortSignal } = {}; + const oldCatalog = new Promise((resolve) => { + resolveOldCatalog = resolve; + }); + let catalogGeneration: "old" | "new" = "old"; + fetchImpl.mockImplementation((input: string | URL, init?: RequestInit) => { + if (String(input).endsWith("/api/commands")) { + if (catalogGeneration === "old" && init?.signal) { + oldCatalogCapture.signal = init.signal; + } + return catalogGeneration === "old" + ? oldCatalog + : Promise.resolve(response({ commands: [commandWire("/fresh")] })); + } + return originalFetch(input, init); + }); + + sockets[0]!.disconnect(); + await waitUntil(() => sockets.length === 2); + const second = sockets[1]!; + second.open(); + second.message({ event: "ready" }); + second.message({ event: "attached", chat_id: client.chatId }); + second.message({ + event: "message_queue_snapshot", + chat_id: client.chatId, + revision: 0, + items: [], + started_items: [], + }); + await waitUntil(() => client.snapshot().connection === "connected"); + expect(client.snapshot()).toMatchObject({ slashCommandsStatus: "stale" }); + expect(client.snapshot().slashCommands.map((item) => item.command)).toEqual([ + "/model", + "/history", + ]); + expect(oldCatalogCapture.signal?.aborted).toBe(false); + + catalogGeneration = "new"; + second.disconnect(); + expect(oldCatalogCapture.signal?.aborted).toBe(true); + await waitUntil(() => sockets.length === 3); + const third = sockets[2]!; + third.open(); + third.message({ event: "ready" }); + third.message({ event: "attached", chat_id: client.chatId }); + third.message({ + event: "message_queue_snapshot", + chat_id: client.chatId, + revision: 0, + items: [], + started_items: [], + }); + await waitUntil(() => client.snapshot().slashCommandsStatus === "ready"); + expect(client.snapshot().slashCommands.map((item) => item.command)).toEqual(["/fresh"]); + + resolveOldCatalog(response({ commands: [commandWire("/obsolete")] })); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(client.snapshot().slashCommands.map((item) => item.command)).toEqual(["/fresh"]); + client.close(); + }); + + it("reads last compaction once with the current token and aborts it on disconnect", async () => { + const { client, fetchImpl, sockets } = await connectClient(); + const originalFetch = fetchImpl.getMockImplementation(); + if (!originalFetch) throw new Error("fetch mock is missing its implementation"); + let resolveCompaction!: (value: Response) => void; + const compaction = new Promise((resolve) => { + resolveCompaction = resolve; + }); + let phase: "available" | "empty" | "invalid" | "pending" = "available"; + fetchImpl.mockImplementation((input: string | URL, init?: RequestInit) => { + if (!String(input).endsWith("/last-compaction")) return originalFetch(input, init); + if (phase === "available") return compaction; + if (phase === "empty") { + return Promise.resolve(response({ + available: false, + sessionKey: `websocket:${client.chatId}`, + mode: null, + text: "", + lastActive: null, + })); + } + if (phase === "invalid") { + return Promise.resolve(response({ + available: true, + sessionKey: "websocket:another-session", + mode: null, + text: "", + lastActive: null, + })); + } + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }); + }); + + const first = client.readLastCompaction(); + const duplicate = client.readLastCompaction(); + expect(duplicate).toBe(first); + const calls = fetchImpl.mock.calls.filter(([url]) => String(url).endsWith("/last-compaction")); + expect(calls).toHaveLength(1); + const sessionKey = `websocket:${client.chatId}`; + expect(String(calls[0]![0])).toBe( + `http://127.0.0.1:18980/api/sessions/${encodeURIComponent(sessionKey)}/last-compaction`, + ); + expect(calls[0]![1]).toMatchObject({ + headers: { authorization: "Bearer token-1" }, + signal: expect.any(AbortSignal), + }); + resolveCompaction(response({ + available: true, + sessionKey: `websocket:${client.chatId}`, + mode: "dag", + text: "summary line one\nsummary line two", + lastActive: "2026-08-28T01:02:03.000Z", + dagSnapshotId: "snapshot-1", + })); + await expect(first).resolves.toMatchObject({ + available: true, + mode: "dag", + text: "summary line one\nsummary line two", + }); + + phase = "empty"; + await expect(client.readLastCompaction()).resolves.toMatchObject({ + available: false, + mode: null, + }); + + phase = "invalid"; + await expect(client.readLastCompaction()).rejects.toThrow("invalid"); + + phase = "pending"; + const pending = client.readLastCompaction(); + sockets[0]!.disconnect(); + await expect(pending).rejects.toThrow("aborted"); + client.close(); + }); }); diff --git a/App/memmy-agent/tests/entrypoints/cli/tui-slash-menu.test.tsx b/App/memmy-agent/tests/entrypoints/cli/tui-slash-menu.test.tsx new file mode 100644 index 00000000..fb0ec62c --- /dev/null +++ b/App/memmy-agent/tests/entrypoints/cli/tui-slash-menu.test.tsx @@ -0,0 +1,174 @@ +import stringWidth from "string-width"; +import { describe, expect, it } from "vitest"; +import type { TuiSlashCommand } from "../../../src/entrypoints/cli/tui-gateway-client.js"; +import { + buildTuiSlashCommands, + classifyTuiInput, + completeTuiSlashCommand, + formatTuiSlashMenuRows, + isTuiSlashDraft, + isTuiSlashMenuOpen, + queryTuiSlashCommands, + slashMenuStatusText, + tuiSlashMenuRowCount, + tuiVisibleMessageCount, +} from "../../../src/entrypoints/cli/tui-slash-menu.js"; + +function command(name: string, title = `${name} title`, argHint = ""): TuiSlashCommand { + return { + command: name, + title, + description: `${name} searchable description`, + icon: "test", + argHint, + source: "gateway", + }; +} + +const idle = { + connection: "connected" as const, + attached: true, + busy: false, + ownedByTui: false, + activeTurnId: null, +}; + +describe("TUI slash menu", () => { + it("composes primary commands without duplicating local or unsafe Gateway commands", () => { + const gateway = [ + command("/future-z"), + command("/quit"), + command("/restart"), + command("/history-dag"), + command("/stop"), + command("/help"), + command("/new"), + command("/status"), + command("/model", "Switch model preset", "[list|preset]"), + command("/history", "Show conversation history", "[n]"), + command("/goal"), + command("/future-a"), + ]; + + expect(buildTuiSlashCommands(gateway, idle).map((item) => item.command)).toEqual([ + "/new", + "/status", + "/model", + "/history", + "/last-compaction", + "/goal", + "/help", + "/quit", + "/history-dag", + "/future-z", + "/future-a", + ]); + }); + + it("shows local stop only for the exact active TUI ownership state", () => { + const gateway = [command("/new"), command("/status")]; + const owned = buildTuiSlashCommands(gateway, { + ...idle, + busy: true, + ownedByTui: true, + activeTurnId: "turn-tui", + }); + expect(owned.map((item) => item.command)).toEqual([ + "/stop", + "/new", + "/status", + "/last-compaction", + "/quit", + ]); + expect(owned[0]).toMatchObject({ source: "local", title: "Stop current TUI Turn" }); + + const external = buildTuiSlashCommands(gateway, { + ...idle, + busy: true, + ownedByTui: false, + activeTurnId: null, + }); + expect(external.map((item) => item.command)).toEqual(["/status", "/last-compaction", "/quit"]); + }); + + it("classifies only the three TUI-local actions and preserves manual Gateway commands", () => { + expect(classifyTuiInput(" /stop ")).toBe("local-stop"); + expect(classifyTuiInput("/LAST-COMPACTION")).toBe("local-last-compaction"); + for (const alias of ["/quit", "/exit", "exit", "quit", ":q"]) { + expect(classifyTuiInput(alias)).toBe("local-quit"); + } + for (const value of ["/new", "/restart", "/help", "/history-dag", "/stop now"]) { + expect(classifyTuiInput(value)).toBe("gateway"); + } + }); + + it("recognizes one-token slash drafts and reopens only after the text changes", () => { + expect(isTuiSlashDraft("/his")).toBe(true); + expect(isTuiSlashDraft("/history-dag")).toBe(true); + expect(isTuiSlashDraft("/history ")).toBe(false); + expect(isTuiSlashDraft("/goal create")).toBe(false); + expect(isTuiSlashDraft("./path")).toBe(false); + expect(isTuiSlashMenuOpen("/his", null)).toBe(true); + expect(isTuiSlashMenuOpen("/his", "/his")).toBe(false); + expect(isTuiSlashMenuOpen("/hist", "/his")).toBe(true); + }); + + it("ranks exact, prefix, then searchable matches and returns at most eight", () => { + const commands = [ + command("/model", "Switch model preset", "[list|preset]"), + command("/models-extra"), + command("/status", "Model runtime status"), + ...Array.from({ length: 8 }, (_, index) => + command(`/future-${index}`, `Model future ${index}`), + ), + ]; + expect(queryTuiSlashCommands(commands, "/model").map((item) => item.command)).toEqual([ + "/model", + "/models-extra", + "/status", + "/future-0", + "/future-1", + "/future-2", + "/future-3", + "/future-4", + ]); + expect(queryTuiSlashCommands(commands, "/")).toHaveLength(8); + expect(queryTuiSlashCommands(commands, "/missing")).toEqual([]); + }); + + it("completes argument commands with a space while exact Enter can still execute", () => { + expect(completeTuiSlashCommand(command("/model", "Switch model", "[list|preset]"))).toBe( + "/model ", + ); + expect(completeTuiSlashCommand(command("/quit"))).toBe("/quit"); + }); + + it("keeps wide and narrow menu rows single-line and caps the message window", () => { + const commands = [ + command("/model", "切换模型预设", "[list|preset]"), + command("/history", "Show conversation history", "[n]"), + ]; + const wide = formatTuiSlashMenuRows(commands, 0, 52); + expect(wide).toMatchInlineSnapshot(` + [ + "› /model [list|preset] 切换模型预设", + " /history [n] Show conversation history", + ] + `); + const narrow = formatTuiSlashMenuRows(commands, 1, 12); + expect(narrow.every((row) => !row.includes("\n") && stringWidth(row) <= 12)).toBe(true); + expect(tuiSlashMenuRowCount(true, 0)).toBe(1); + expect(tuiSlashMenuRowCount(true, 8)).toBe(8); + expect(tuiVisibleMessageCount(8)).toBe(0); + expect(tuiVisibleMessageCount(0)).toBe(8); + }); + + it("uses fixed local-only fallback messages without replacing stale candidates", () => { + expect(slashMenuStatusText("loading")).toBe("Loading Gateway commands..."); + expect(slashMenuStatusText("error")).toBe( + "Gateway commands unavailable; local commands remain available.", + ); + expect(slashMenuStatusText("ready")).toBe("No matching command."); + expect(slashMenuStatusText("stale")).toBe("No matching command."); + }); +}); diff --git a/App/memmy-agent/tests/entrypoints/cli/tui-turn-admission.test.tsx b/App/memmy-agent/tests/entrypoints/cli/tui-turn-admission.test.tsx index 81ef8872..344eecfa 100644 --- a/App/memmy-agent/tests/entrypoints/cli/tui-turn-admission.test.tsx +++ b/App/memmy-agent/tests/entrypoints/cli/tui-turn-admission.test.tsx @@ -12,11 +12,38 @@ const source = readFileSync( describe("Ink TUI Turn admission", () => { it("maps Enter to queue and Tab to steer", () => { - expect(source).toContain('submit(inputRef.current, "queue");'); + expect(source).toContain("dispatchTuiInput(inputRef.current);"); + expect(source).toContain('submit(value, "queue");'); expect(source).toContain('submit(inputRef.current, "steer");'); expect(source).toContain("gateway.submit(text, turnAdmission, request.clientRequestId)"); }); + it("handles slash completion before Queue and never routes slash Tab to Steer", () => { + const ctrlC = source.indexOf('if (key.ctrl && value === "c")'); + const slashEscape = source.indexOf("if (slashMenuOpen && key.escape)"); + const slashTab = source.indexOf("if (key.tab && slashInput)"); + const enter = source.indexOf("if (key.return)", slashTab); + const steer = source.indexOf("if (key.tab && gatewayState.ownedByTui)", enter); + expect(ctrlC).toBeGreaterThanOrEqual(0); + expect(slashEscape).toBeGreaterThan(ctrlC); + expect(slashTab).toBeGreaterThan(slashEscape); + expect(enter).toBeGreaterThan(slashTab); + expect(steer).toBeGreaterThan(enter); + expect(source).toContain('const slashInput = inputRef.current.trimStart().startsWith("/");'); + }); + + it("keeps only stop, last-compaction, and quit local", () => { + expect(source).toContain('if (action === "local-stop")'); + expect(source).toContain("gateway.stopOwnedTurn()"); + expect(source).toContain('if (action === "local-last-compaction")'); + expect(source).toContain("gateway.readLastCompaction()"); + expect(source).toContain('if (action === "local-quit")'); + expect(source).toContain('submit(value, "queue");'); + expect(source).not.toContain('action === "local-new"'); + expect(source).not.toContain('action === "local-restart"'); + expect(source).not.toContain('action === "local-help"'); + }); + it("keeps the composer active while the Session is busy", () => { expect(source).not.toContain("if (busy) return;"); expect(source).toContain("Enter: queue next turn · Tab: add to current turn");