diff --git a/docs/refactor/tui.md b/docs/refactor/tui.md new file mode 100644 index 000000000..f93b73a37 --- /dev/null +++ b/docs/refactor/tui.md @@ -0,0 +1,26 @@ +--- +summary: "Refactor plan: Gateway TUI parity with pi-mono interactive UI" +read_when: + - Building or refactoring the Gateway TUI + - Syncing TUI slash commands with Clawdis behavior +--- +# Gateway TUI refactor plan + +Updated: 2026-01-03 + +## Goals +- Match pi-mono interactive TUI feel (editor, streaming, tool cards, selectors). +- Keep Clawdis semantics: Gateway WS only, session store owns state, no branching/export. +- Work locally or remotely via Gateway URL/token. + +## Non-goals +- Branching, export, OAuth flows, or hook UIs. +- File-system operations on the Gateway host from the TUI. + +## Checklist +- [x] Protocol + server: sessions.patch supports model overrides; agent events include tool results (text-only payloads). +- [ ] Gateway TUI client: add session/model helpers + stricter typing. +- [ ] TUI UI kit: theme + components (editor, message feed, tool cards, selectors). +- [ ] TUI controller: keybindings + Clawdis slash commands + history/stream wiring. +- [ ] Docs + changelog updated for the new TUI behavior. +- [ ] Gate: lint, build, tests, docs list. diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index fb2b8b6f8..7554f02f4 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -16,6 +16,36 @@ import { const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi; const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i; const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i; +const TOOL_RESULT_MAX_CHARS = 8000; + +function truncateToolText(text: string): string { + if (text.length <= TOOL_RESULT_MAX_CHARS) return text; + return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`; +} + +function sanitizeToolResult(result: unknown): unknown { + if (!result || typeof result !== "object") return result; + const record = result as Record; + const content = Array.isArray(record.content) ? record.content : null; + if (!content) return record; + const sanitized = content.map((item) => { + if (!item || typeof item !== "object") return item; + const entry = item as Record; + const type = typeof entry.type === "string" ? entry.type : undefined; + if (type === "text" && typeof entry.text === "string") { + return { ...entry, text: truncateToolText(entry.text) }; + } + if (type === "image") { + const data = typeof entry.data === "string" ? entry.data : undefined; + const bytes = data ? data.length : undefined; + const cleaned = { ...entry }; + delete cleaned.data; + return { ...cleaned, bytes, omitted: true }; + } + return entry; + }); + return { ...record, content: sanitized }; +} function stripThinkingSegments(text: string): string { if (!text || !THINKING_TAG_RE.test(text)) return text; @@ -187,6 +217,36 @@ export function subscribeEmbeddedPiSession(params: { }); } + if (evt.type === "tool_execution_update") { + const toolName = String( + (evt as AgentEvent & { toolName: string }).toolName, + ); + const toolCallId = String( + (evt as AgentEvent & { toolCallId: string }).toolCallId, + ); + const partial = (evt as AgentEvent & { partialResult?: unknown }) + .partialResult; + const sanitized = sanitizeToolResult(partial); + emitAgentEvent({ + runId: params.runId, + stream: "tool", + data: { + phase: "update", + name: toolName, + toolCallId, + partialResult: sanitized, + }, + }); + params.onAgentEvent?.({ + stream: "tool", + data: { + phase: "update", + name: toolName, + toolCallId, + }, + }); + } + if (evt.type === "tool_execution_end") { const toolName = String( (evt as AgentEvent & { toolName: string }).toolName, @@ -197,6 +257,8 @@ export function subscribeEmbeddedPiSession(params: { const isError = Boolean( (evt as AgentEvent & { isError: boolean }).isError, ); + const result = (evt as AgentEvent & { result?: unknown }).result; + const sanitizedResult = sanitizeToolResult(result); const meta = toolMetaById.get(toolCallId); toolMetas.push({ toolName, meta }); toolDebouncer.push(toolName, meta); @@ -210,6 +272,7 @@ export function subscribeEmbeddedPiSession(params: { toolCallId, meta, isError, + result: sanitizedResult, }, }); params.onAgentEvent?.({ diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 8ba174494..104d1cecc 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -297,6 +297,7 @@ export const SessionsPatchParamsSchema = Type.Object( key: NonEmptyString, thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), groupActivation: Type.Optional( Type.Union([ Type.Literal("mention"), diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 903f63932..8bfe26d78 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -4074,6 +4074,16 @@ describe("gateway server", () => { expect(main2?.thinkingLevel).toBe("medium"); expect(main2?.verboseLevel).toBeUndefined(); + piSdkMock.enabled = true; + piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; + const modelPatched = await rpcReq<{ + ok: true; + entry: { modelOverride?: string; providerOverride?: string }; + }>(ws, "sessions.patch", { key: "main", model: "openai/gpt-test-a" }); + expect(modelPatched.ok).toBe(true); + expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a"); + expect(modelPatched.payload?.entry.providerOverride).toBe("openai"); + const compacted = await rpcReq<{ ok: true; compacted: boolean }>( ws, "sessions.compact", diff --git a/src/gateway/server.ts b/src/gateway/server.ts index d95b9cac5..06376c5b2 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -20,7 +20,13 @@ import { type ModelCatalogEntry, resetModelCatalogCacheForTest, } from "../agents/model-catalog.js"; -import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { + buildAllowedModelSet, + buildModelAliasIndex, + modelKey, + resolveConfiguredModelRef, + resolveModelRefFromString, +} from "../agents/model-selection.js"; import { installSkill } from "../agents/skills-install.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js"; @@ -2958,6 +2964,74 @@ export async function startGatewayServer( } } + if ("model" in p) { + const raw = p.model; + if (raw === null) { + delete next.providerOverride; + delete next.modelOverride; + } else if (raw !== undefined) { + const trimmed = String(raw).trim(); + if (!trimmed) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "invalid model: empty", + }, + }; + } + const resolvedDefault = resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const aliasIndex = buildModelAliasIndex({ + cfg, + defaultProvider: resolvedDefault.provider, + }); + const resolved = resolveModelRefFromString({ + raw: trimmed, + defaultProvider: resolvedDefault.provider, + aliasIndex, + }); + if (!resolved) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid model: ${trimmed}`, + }, + }; + } + const catalog = await loadGatewayModelCatalog(); + const allowed = buildAllowedModelSet({ + cfg, + catalog, + defaultProvider: resolvedDefault.provider, + }); + const key = modelKey(resolved.ref.provider, resolved.ref.model); + if (!allowed.allowAny && !allowed.allowedKeys.has(key)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `model not allowed: ${key}`, + }, + }; + } + if ( + resolved.ref.provider === resolvedDefault.provider && + resolved.ref.model === resolvedDefault.model + ) { + delete next.providerOverride; + delete next.modelOverride; + } else { + next.providerOverride = resolved.ref.provider; + next.modelOverride = resolved.ref.model; + } + } + } + if ("groupActivation" in p) { const raw = p.groupActivation; if (raw === null) {