refactor(cli): centralize command execution flow (#841)
This commit is contained in:
2501
src/cli/App.tsx
2501
src/cli/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,12 @@ function uid(prefix: string) {
|
||||
// Helper type for command result
|
||||
type CommandLine = Extract<Line, { kind: "command" }>;
|
||||
|
||||
let activeCommandId: string | null = null;
|
||||
|
||||
export function setActiveCommandId(id: string | null): void {
|
||||
activeCommandId = id;
|
||||
}
|
||||
|
||||
// Context passed to connect handlers
|
||||
export interface ConnectCommandContext {
|
||||
buffersRef: { current: Buffers };
|
||||
@@ -66,17 +72,22 @@ function addCommandResult(
|
||||
success: boolean,
|
||||
phase: "running" | "finished" = "finished",
|
||||
): string {
|
||||
const cmdId = uid("cmd");
|
||||
const cmdId = activeCommandId ?? uid("cmd");
|
||||
const existing = buffersRef.current.byId.get(cmdId);
|
||||
const nextInput =
|
||||
existing && existing.kind === "command" ? existing.input : input;
|
||||
const line: CommandLine = {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input,
|
||||
input: nextInput,
|
||||
output,
|
||||
phase,
|
||||
...(phase === "finished" && { success }),
|
||||
};
|
||||
buffersRef.current.byId.set(cmdId, line);
|
||||
buffersRef.current.order.push(cmdId);
|
||||
if (!buffersRef.current.order.includes(cmdId)) {
|
||||
buffersRef.current.order.push(cmdId);
|
||||
}
|
||||
refreshDerived();
|
||||
return cmdId;
|
||||
}
|
||||
@@ -91,10 +102,13 @@ function updateCommandResult(
|
||||
success: boolean,
|
||||
phase: "running" | "finished" = "finished",
|
||||
): void {
|
||||
const existing = buffersRef.current.byId.get(cmdId);
|
||||
const nextInput =
|
||||
existing && existing.kind === "command" ? existing.input : input;
|
||||
const line: CommandLine = {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input,
|
||||
input: nextInput,
|
||||
output,
|
||||
phase,
|
||||
...(phase === "finished" && { success }),
|
||||
|
||||
@@ -18,6 +18,12 @@ function uid(prefix: string) {
|
||||
// Helper type for command result
|
||||
type CommandLine = Extract<Line, { kind: "command" }>;
|
||||
|
||||
let activeCommandId: string | null = null;
|
||||
|
||||
export function setActiveCommandId(id: string | null): void {
|
||||
activeCommandId = id;
|
||||
}
|
||||
|
||||
// Context passed to MCP handlers
|
||||
export interface McpCommandContext {
|
||||
buffersRef: { current: Buffers };
|
||||
@@ -34,17 +40,22 @@ export function addCommandResult(
|
||||
success: boolean,
|
||||
phase: "running" | "finished" = "finished",
|
||||
): string {
|
||||
const cmdId = uid("cmd");
|
||||
const cmdId = activeCommandId ?? uid("cmd");
|
||||
const existing = buffersRef.current.byId.get(cmdId);
|
||||
const nextInput =
|
||||
existing && existing.kind === "command" ? existing.input : input;
|
||||
const line: CommandLine = {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input,
|
||||
input: nextInput,
|
||||
output,
|
||||
phase,
|
||||
...(phase === "finished" && { success }),
|
||||
};
|
||||
buffersRef.current.byId.set(cmdId, line);
|
||||
buffersRef.current.order.push(cmdId);
|
||||
if (!buffersRef.current.order.includes(cmdId)) {
|
||||
buffersRef.current.order.push(cmdId);
|
||||
}
|
||||
refreshDerived();
|
||||
return cmdId;
|
||||
}
|
||||
@@ -59,10 +70,13 @@ export function updateCommandResult(
|
||||
success: boolean,
|
||||
phase: "running" | "finished" = "finished",
|
||||
): void {
|
||||
const existing = buffersRef.current.byId.get(cmdId);
|
||||
const nextInput =
|
||||
existing && existing.kind === "command" ? existing.input : input;
|
||||
const line: CommandLine = {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input,
|
||||
input: nextInput,
|
||||
output,
|
||||
phase,
|
||||
...(phase === "finished" && { success }),
|
||||
|
||||
@@ -14,6 +14,12 @@ function uid(prefix: string) {
|
||||
// Helper type for command result
|
||||
type CommandLine = Extract<Line, { kind: "command" }>;
|
||||
|
||||
let activeCommandId: string | null = null;
|
||||
|
||||
export function setActiveCommandId(id: string | null): void {
|
||||
activeCommandId = id;
|
||||
}
|
||||
|
||||
// Context passed to profile handlers
|
||||
export interface ProfileCommandContext {
|
||||
buffersRef: { current: Buffers };
|
||||
@@ -33,17 +39,22 @@ export function addCommandResult(
|
||||
success: boolean,
|
||||
phase: "running" | "finished" = "finished",
|
||||
): string {
|
||||
const cmdId = uid("cmd");
|
||||
const cmdId = activeCommandId ?? uid("cmd");
|
||||
const existing = buffersRef.current.byId.get(cmdId);
|
||||
const nextInput =
|
||||
existing && existing.kind === "command" ? existing.input : input;
|
||||
const line: CommandLine = {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input,
|
||||
input: nextInput,
|
||||
output,
|
||||
phase,
|
||||
...(phase === "finished" && { success }),
|
||||
};
|
||||
buffersRef.current.byId.set(cmdId, line);
|
||||
buffersRef.current.order.push(cmdId);
|
||||
if (!buffersRef.current.order.includes(cmdId)) {
|
||||
buffersRef.current.order.push(cmdId);
|
||||
}
|
||||
refreshDerived();
|
||||
return cmdId;
|
||||
}
|
||||
@@ -58,10 +69,13 @@ export function updateCommandResult(
|
||||
success: boolean,
|
||||
phase: "running" | "finished" = "finished",
|
||||
): void {
|
||||
const existing = buffersRef.current.byId.get(cmdId);
|
||||
const nextInput =
|
||||
existing && existing.kind === "command" ? existing.input : input;
|
||||
const line: CommandLine = {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input,
|
||||
input: nextInput,
|
||||
output,
|
||||
phase,
|
||||
...(phase === "finished" && { success }),
|
||||
|
||||
107
src/cli/commands/runner.ts
Normal file
107
src/cli/commands/runner.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Buffers, Line } from "../helpers/accumulator";
|
||||
|
||||
export type CommandPhase = "running" | "waiting" | "finished";
|
||||
|
||||
export type CommandUpdate = {
|
||||
output: string;
|
||||
phase?: CommandPhase;
|
||||
success?: boolean;
|
||||
dimOutput?: boolean;
|
||||
preformatted?: boolean;
|
||||
};
|
||||
|
||||
export type CommandHandle = {
|
||||
id: string;
|
||||
input: string;
|
||||
update: (update: CommandUpdate) => void;
|
||||
finish: (
|
||||
output: string,
|
||||
success?: boolean,
|
||||
dimOutput?: boolean,
|
||||
preformatted?: boolean,
|
||||
) => void;
|
||||
fail: (output: string) => void;
|
||||
};
|
||||
|
||||
type CreateId = (prefix: string) => string;
|
||||
|
||||
type RunnerDeps = {
|
||||
buffersRef: MutableRefObject<Buffers>;
|
||||
refreshDerived: () => void;
|
||||
createId: CreateId;
|
||||
};
|
||||
|
||||
function upsertCommandLine(
|
||||
buffers: Buffers,
|
||||
id: string,
|
||||
input: string,
|
||||
update: CommandUpdate,
|
||||
): void {
|
||||
const existing = buffers.byId.get(id);
|
||||
const next: Line = {
|
||||
kind: "command",
|
||||
id,
|
||||
input: existing?.kind === "command" ? existing.input : input,
|
||||
output: update.output,
|
||||
phase: update.phase ?? "running",
|
||||
success: update.success,
|
||||
dimOutput: update.dimOutput,
|
||||
preformatted: update.preformatted,
|
||||
};
|
||||
buffers.byId.set(id, next);
|
||||
}
|
||||
|
||||
export function createCommandRunner({
|
||||
buffersRef,
|
||||
refreshDerived,
|
||||
createId,
|
||||
}: RunnerDeps) {
|
||||
function getHandle(id: string, input: string): CommandHandle {
|
||||
const update = (updateData: CommandUpdate) => {
|
||||
upsertCommandLine(buffersRef.current, id, input, updateData);
|
||||
if (!buffersRef.current.order.includes(id)) {
|
||||
buffersRef.current.order.push(id);
|
||||
}
|
||||
refreshDerived();
|
||||
};
|
||||
|
||||
const finish = (
|
||||
finalOutput: string,
|
||||
success = true,
|
||||
dimOutput?: boolean,
|
||||
preformatted?: boolean,
|
||||
) =>
|
||||
update({
|
||||
output: finalOutput,
|
||||
phase: "finished",
|
||||
success,
|
||||
dimOutput,
|
||||
preformatted,
|
||||
});
|
||||
|
||||
const fail = (finalOutput: string) =>
|
||||
update({
|
||||
output: finalOutput,
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
|
||||
return { id, input, update, finish, fail };
|
||||
}
|
||||
|
||||
function start(input: string, output: string): CommandHandle {
|
||||
const id = createId("cmd");
|
||||
const buffers = buffersRef.current;
|
||||
upsertCommandLine(buffers, id, input, {
|
||||
output,
|
||||
phase: "running",
|
||||
});
|
||||
buffers.order.push(id);
|
||||
refreshDerived();
|
||||
|
||||
return getHandle(id, input);
|
||||
}
|
||||
|
||||
return { start, getHandle };
|
||||
}
|
||||
@@ -11,7 +11,7 @@ type CommandLine = {
|
||||
id: string;
|
||||
input: string;
|
||||
output: string;
|
||||
phase?: "running" | "finished";
|
||||
phase?: "running" | "waiting" | "finished";
|
||||
success?: boolean;
|
||||
dimOutput?: boolean;
|
||||
preformatted?: boolean;
|
||||
@@ -30,6 +30,9 @@ type CommandLine = {
|
||||
export const CommandMessage = memo(({ line }: { line: CommandLine }) => {
|
||||
const columns = useTerminalWidth();
|
||||
const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols
|
||||
if (line.phase === "waiting") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine dot state based on phase and success
|
||||
const getDotElement = () => {
|
||||
|
||||
@@ -88,7 +88,7 @@ const _colors = {
|
||||
selected: brandColors.primaryAccent,
|
||||
inactive: brandColors.textDisabled, // uses dimColor prop
|
||||
border: brandColors.textDisabled,
|
||||
running: brandColors.statusWarning,
|
||||
running: brandColors.textSecondary,
|
||||
error: brandColors.statusError,
|
||||
},
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ export type Line =
|
||||
id: string;
|
||||
input: string;
|
||||
output: string;
|
||||
phase?: "running" | "finished";
|
||||
phase?: "running" | "waiting" | "finished";
|
||||
success?: boolean;
|
||||
dimOutput?: boolean;
|
||||
preformatted?: boolean;
|
||||
|
||||
156
src/tests/cli/commandRunner.test.ts
Normal file
156
src/tests/cli/commandRunner.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
handleMcpUsage,
|
||||
setActiveCommandId as setActiveMcpCommandId,
|
||||
} from "../../cli/commands/mcp";
|
||||
import {
|
||||
addCommandResult,
|
||||
setActiveCommandId as setActiveProfileCommandId,
|
||||
updateCommandResult,
|
||||
} from "../../cli/commands/profile";
|
||||
import { createCommandRunner } from "../../cli/commands/runner";
|
||||
import { createBuffers } from "../../cli/helpers/accumulator";
|
||||
|
||||
describe("commandRunner", () => {
|
||||
test("start/finish writes a single command line", () => {
|
||||
const buffers = createBuffers();
|
||||
const buffersRef = { current: buffers };
|
||||
let refreshCount = 0;
|
||||
const runner = createCommandRunner({
|
||||
buffersRef,
|
||||
refreshDerived: () => {
|
||||
refreshCount += 1;
|
||||
},
|
||||
createId: () => "cmd-1",
|
||||
});
|
||||
|
||||
const cmd = runner.start("/model", "Opening model selector...");
|
||||
expect(cmd.id).toBe("cmd-1");
|
||||
expect(buffers.order).toEqual(["cmd-1"]);
|
||||
expect(buffers.byId.get("cmd-1")).toMatchObject({
|
||||
kind: "command",
|
||||
input: "/model",
|
||||
output: "Opening model selector...",
|
||||
phase: "running",
|
||||
});
|
||||
|
||||
cmd.finish("Done", true);
|
||||
expect(buffers.byId.get("cmd-1")).toMatchObject({
|
||||
kind: "command",
|
||||
input: "/model",
|
||||
output: "Done",
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
expect(refreshCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("getHandle preserves existing input and order", () => {
|
||||
const buffers = createBuffers();
|
||||
const buffersRef = { current: buffers };
|
||||
buffers.byId.set("cmd-1", {
|
||||
kind: "command",
|
||||
id: "cmd-1",
|
||||
input: "/connect",
|
||||
output: "Starting...",
|
||||
phase: "running",
|
||||
});
|
||||
buffers.order.push("cmd-1");
|
||||
|
||||
const runner = createCommandRunner({
|
||||
buffersRef,
|
||||
refreshDerived: () => {},
|
||||
createId: () => "cmd-ignored",
|
||||
});
|
||||
|
||||
const cmd = runner.getHandle("cmd-1", "/connect codex");
|
||||
cmd.update({ output: "Still running...", phase: "running" });
|
||||
|
||||
const line = buffers.byId.get("cmd-1");
|
||||
expect(line).toMatchObject({
|
||||
kind: "command",
|
||||
input: "/connect",
|
||||
output: "Still running...",
|
||||
phase: "running",
|
||||
});
|
||||
expect(buffers.order).toEqual(["cmd-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("command input preservation in handlers", () => {
|
||||
test("mcp usage keeps original input when reusing command id", () => {
|
||||
const buffers = createBuffers();
|
||||
const buffersRef = { current: buffers };
|
||||
buffers.byId.set("cmd-1", {
|
||||
kind: "command",
|
||||
id: "cmd-1",
|
||||
input: "/mcp",
|
||||
output: "",
|
||||
phase: "running",
|
||||
});
|
||||
buffers.order.push("cmd-1");
|
||||
|
||||
setActiveMcpCommandId("cmd-1");
|
||||
handleMcpUsage(
|
||||
{
|
||||
buffersRef,
|
||||
refreshDerived: () => {},
|
||||
setCommandRunning: () => {},
|
||||
},
|
||||
"/mcp add",
|
||||
);
|
||||
setActiveMcpCommandId(null);
|
||||
|
||||
const line = buffers.byId.get("cmd-1");
|
||||
expect(line).toMatchObject({
|
||||
kind: "command",
|
||||
input: "/mcp",
|
||||
});
|
||||
expect(line?.kind).toBe("command");
|
||||
if (line && line.kind === "command") {
|
||||
expect(line.output).toContain("Usage: /mcp");
|
||||
}
|
||||
});
|
||||
|
||||
test("profile updates keep original input when reusing command id", () => {
|
||||
const buffers = createBuffers();
|
||||
const buffersRef = { current: buffers };
|
||||
buffers.byId.set("cmd-1", {
|
||||
kind: "command",
|
||||
id: "cmd-1",
|
||||
input: "/profile",
|
||||
output: "",
|
||||
phase: "running",
|
||||
});
|
||||
buffers.order.push("cmd-1");
|
||||
|
||||
setActiveProfileCommandId("cmd-1");
|
||||
addCommandResult(
|
||||
buffersRef,
|
||||
() => {},
|
||||
"/profile save test",
|
||||
"Saving...",
|
||||
false,
|
||||
"running",
|
||||
);
|
||||
updateCommandResult(
|
||||
buffersRef,
|
||||
() => {},
|
||||
"cmd-1",
|
||||
"/profile delete test",
|
||||
"Done",
|
||||
true,
|
||||
"finished",
|
||||
);
|
||||
setActiveProfileCommandId(null);
|
||||
|
||||
const line = buffers.byId.get("cmd-1");
|
||||
expect(line).toMatchObject({
|
||||
kind: "command",
|
||||
input: "/profile",
|
||||
output: "Done",
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user