Merge PR #3401: feat(move): interactive /move overlay with fresh session (@oldschoola)
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as fsp from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Settings } from "../../../config/settings";
|
||||
import { getThemeByName, setThemeInstance, type Theme } from "../../theme/theme";
|
||||
import { MoveOverlay, type MoveOverlayResult, resolveExistingDirectory, resolveMovePath } from "../move-overlay";
|
||||
|
||||
// Strip SGR colors so assertions see visible text only.
|
||||
const strip = (lines: readonly string[]): string => lines.join("\n").replace(/\x1b\[[0-9;]*m/g, "");
|
||||
|
||||
describe("resolveMovePath", () => {
|
||||
it("expands ~ to homedir", () => {
|
||||
expect(resolveMovePath("~", "/anywhere")).toBe(os.homedir());
|
||||
});
|
||||
it("expands ~/sub to homedir/sub", () => {
|
||||
expect(resolveMovePath("~/foo", "/anywhere")).toBe(path.join(os.homedir(), "foo"));
|
||||
});
|
||||
it("resolves relative paths against cwd", () => {
|
||||
expect(resolveMovePath("foo/bar", "/parent")).toBe(path.resolve("/parent", "foo/bar"));
|
||||
});
|
||||
it("passes absolute paths through (normalized)", () => {
|
||||
expect(resolveMovePath("/abs/path", "/anywhere")).toBe(path.normalize("/abs/path"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveExistingDirectory", () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-resolve-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns the resolved path for an existing directory", () => {
|
||||
const sub = path.join(tmp, "sub");
|
||||
fs.mkdirSync(sub);
|
||||
expect(resolveExistingDirectory(sub, "/anywhere")).toBe(path.resolve(sub));
|
||||
});
|
||||
it("returns null for a non-existent path", () => {
|
||||
expect(resolveExistingDirectory(path.join(tmp, "nope"), "/anywhere")).toBeNull();
|
||||
});
|
||||
it("returns null for a file (not a directory)", () => {
|
||||
const file = path.join(tmp, "file.txt");
|
||||
fs.writeFileSync(file, "x");
|
||||
expect(resolveExistingDirectory(file, "/anywhere")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MoveOverlay", () => {
|
||||
let tmp: string;
|
||||
let cwd: string;
|
||||
let uiTheme: Theme;
|
||||
|
||||
beforeAll(async () => {
|
||||
await Settings.init({ inMemory: true });
|
||||
const loaded = await getThemeByName("dark");
|
||||
if (!loaded) throw new Error("theme unavailable");
|
||||
uiTheme = loaded;
|
||||
setThemeInstance(uiTheme);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-move-overlay-"));
|
||||
cwd = tmp;
|
||||
fs.mkdirSync(path.join(tmp, "alpha"));
|
||||
fs.mkdirSync(path.join(tmp, "beta"));
|
||||
fs.mkdirSync(path.join(tmp, ".hidden"));
|
||||
fs.writeFileSync(path.join(tmp, "file.txt"), "x");
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("renders a box with a title and input prompt", () => {
|
||||
const overlay = new MoveOverlay(cwd, () => {});
|
||||
const text = strip(overlay.render(80));
|
||||
expect(text).toContain("Move to directory");
|
||||
expect(text).toContain("Path:");
|
||||
});
|
||||
|
||||
it("lists child directories (excluding hidden and files) on empty input", () => {
|
||||
const overlay = new MoveOverlay(cwd, () => {});
|
||||
const text = strip(overlay.render(80));
|
||||
expect(text).toContain("alpha/");
|
||||
expect(text).toContain("beta/");
|
||||
expect(text).not.toContain(".hidden/");
|
||||
expect(text).not.toContain("file.txt");
|
||||
});
|
||||
|
||||
it("filters results as the user types", () => {
|
||||
const overlay = new MoveOverlay(cwd, () => {});
|
||||
overlay.handleInput("a");
|
||||
overlay.handleInput("l");
|
||||
const text = strip(overlay.render(80));
|
||||
expect(text).toContain("alpha/");
|
||||
expect(text).not.toContain("beta/");
|
||||
});
|
||||
|
||||
it("calls done with undefined on Escape", () => {
|
||||
let result: MoveOverlayResult | undefined = "sentinel" as unknown as MoveOverlayResult;
|
||||
const overlay = new MoveOverlay(cwd, r => {
|
||||
result = r;
|
||||
});
|
||||
overlay.handleInput("\x1b");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("calls done with the highlighted directory on Enter", () => {
|
||||
let result: MoveOverlayResult | undefined;
|
||||
const overlay = new MoveOverlay(cwd, r => {
|
||||
result = r;
|
||||
});
|
||||
// First result should be "alpha/" (sorted alphabetically).
|
||||
overlay.handleInput("\r");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.directory).toBe(path.join(cwd, "alpha"));
|
||||
});
|
||||
|
||||
it("calls done with the typed path on Enter when no results match", () => {
|
||||
let result: MoveOverlayResult | undefined;
|
||||
const overlay = new MoveOverlay(cwd, r => {
|
||||
result = r;
|
||||
});
|
||||
// Type a path that won't match any directory in cwd.
|
||||
overlay.handleInput("z");
|
||||
overlay.handleInput("z");
|
||||
overlay.handleInput("\r");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.directory).toBe("zz");
|
||||
});
|
||||
|
||||
it("Tab accepts the highlighted suggestion into the input", () => {
|
||||
let result: MoveOverlayResult | undefined;
|
||||
const overlay = new MoveOverlay(cwd, r => {
|
||||
result = r;
|
||||
});
|
||||
overlay.handleInput("\t");
|
||||
// After tab, the input should be the full path of the first result.
|
||||
// Press Enter to confirm — the result should be the alpha directory.
|
||||
overlay.handleInput("\r");
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.directory).toBe(path.join(cwd, "alpha"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* `/move` overlay: a path input with live directory autocomplete.
|
||||
*
|
||||
* Rendered as a centered modal via `showHookCustom(..., { overlay: true })`.
|
||||
* The user types a path, Tab autocomtes the highlighted directory, and Enter
|
||||
* confirms — yielding the resolved directory string (or `undefined` on cancel).
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { type Component, CURSOR_MARKER, type Focusable, Key, matchesKey } from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../theme/theme";
|
||||
import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
|
||||
import { bottomBorder, row, topBorder } from "./overlay-box";
|
||||
|
||||
export interface MoveOverlayResult {
|
||||
directory: string;
|
||||
}
|
||||
|
||||
interface DirEntry {
|
||||
/** Full absolute path. */
|
||||
value: string;
|
||||
/** Display label (basename + trailing slash). */
|
||||
label: string;
|
||||
}
|
||||
|
||||
const MAX_RESULTS = 15;
|
||||
const OVERLAY_WIDTH = 68;
|
||||
|
||||
/** TTL for the directory listing cache (ms). */
|
||||
const DIR_CACHE_TTL = 500;
|
||||
const dirCache = new Map<string, { time: number; entries: string[] }>();
|
||||
|
||||
function readDirCached(dir: string): string[] {
|
||||
const now = Date.now();
|
||||
const cached = dirCache.get(dir);
|
||||
if (cached && now - cached.time < DIR_CACHE_TTL) return cached.entries;
|
||||
try {
|
||||
const entries = fs.readdirSync(dir);
|
||||
dirCache.set(dir, { time: now, entries });
|
||||
return entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a user-typed path (`~`, absolute, or relative to `cwd`) to an absolute path. */
|
||||
export function resolveMovePath(input: string, cwd: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === "~") return os.homedir();
|
||||
if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2));
|
||||
if (path.isAbsolute(trimmed)) return path.normalize(trimmed);
|
||||
return path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
/** If `input` resolves to an existing directory, return it; otherwise `null`. */
|
||||
export function resolveExistingDirectory(input: string, cwd: string): string | null {
|
||||
const resolved = resolveMovePath(input, cwd);
|
||||
try {
|
||||
return fs.statSync(resolved).isDirectory() ? resolved : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listChildDirectories(dirPath: string, max: number): DirEntry[] {
|
||||
const results: DirEntry[] = [];
|
||||
const names = readDirCached(dirPath);
|
||||
for (const name of names) {
|
||||
if (results.length >= max) break;
|
||||
if (name.startsWith(".")) continue;
|
||||
const full = path.join(dirPath, name);
|
||||
try {
|
||||
if (!fs.statSync(full).isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
results.push({ value: full, label: `${name}/` });
|
||||
}
|
||||
results.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return results;
|
||||
}
|
||||
|
||||
function searchDirectories(prefix: string, cwd: string, max: number): DirEntry[] {
|
||||
if (!prefix) return listChildDirectories(cwd, max);
|
||||
|
||||
// If the prefix already resolves to an existing directory, list its children.
|
||||
const resolved = resolveExistingDirectory(prefix, cwd);
|
||||
if (resolved) return listChildDirectories(resolved, max);
|
||||
|
||||
// Otherwise, split into base dir + query and filter the base dir's children.
|
||||
const norm = prefix.replace(/\\/g, "/");
|
||||
const slashIdx = norm.lastIndexOf("/");
|
||||
let baseDir: string;
|
||||
let query: string;
|
||||
if (slashIdx === -1) {
|
||||
baseDir = cwd;
|
||||
query = prefix;
|
||||
} else {
|
||||
const base = norm.slice(0, slashIdx + 1);
|
||||
query = norm.slice(slashIdx + 1);
|
||||
baseDir = resolveMovePath(base, cwd);
|
||||
}
|
||||
|
||||
const lower = query.toLowerCase();
|
||||
const results: DirEntry[] = [];
|
||||
const names = readDirCached(baseDir);
|
||||
for (const name of names) {
|
||||
if (results.length >= max) break;
|
||||
if (name.startsWith(".")) continue;
|
||||
const full = path.join(baseDir, name);
|
||||
try {
|
||||
if (!fs.statSync(full).isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!query || name.toLowerCase().includes(lower)) {
|
||||
results.push({ value: full, label: `${name}/` });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay component for `/move`: a single-line path input with a live-filtered
|
||||
* list of matching directories. Tab accepts the highlighted suggestion; Enter
|
||||
* confirms the current input (or the highlighted suggestion if the input is
|
||||
* empty); Escape cancels.
|
||||
*/
|
||||
export class MoveOverlay implements Component, Focusable {
|
||||
#focused = false;
|
||||
#input = "";
|
||||
#cursor = 0;
|
||||
#selectedIndex = 0;
|
||||
#results: DirEntry[] = [];
|
||||
#cwd: string;
|
||||
#done: (result: MoveOverlayResult | undefined) => void;
|
||||
|
||||
constructor(cwd: string, done: (result: MoveOverlayResult | undefined) => void) {
|
||||
this.#cwd = cwd;
|
||||
this.#done = done;
|
||||
// Warm the cache for the current directory so the first keystroke is instant.
|
||||
readDirCached(cwd);
|
||||
this.#updateResults();
|
||||
}
|
||||
|
||||
get focused(): boolean {
|
||||
return this.#focused;
|
||||
}
|
||||
|
||||
set focused(value: boolean) {
|
||||
this.#focused = value;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesSelectCancel(data) || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
||||
this.#done(undefined);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
|
||||
this.#confirm();
|
||||
return;
|
||||
}
|
||||
if (matchesSelectUp(data) || matchesKey(data, Key.up)) {
|
||||
if (this.#results.length > 0) this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
|
||||
return;
|
||||
}
|
||||
if (matchesSelectDown(data) || matchesKey(data, Key.down)) {
|
||||
if (this.#results.length > 0)
|
||||
this.#selectedIndex = Math.min(this.#results.length - 1, this.#selectedIndex + 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.tab)) {
|
||||
const selected = this.#results[this.#selectedIndex];
|
||||
if (selected) {
|
||||
this.#input = selected.value;
|
||||
this.#cursor = this.#input.length;
|
||||
this.#selectedIndex = 0;
|
||||
this.#updateResults();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.left)) {
|
||||
this.#cursor = Math.max(0, this.#cursor - 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.right)) {
|
||||
this.#cursor = Math.min(this.#input.length, this.#cursor + 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.backspace) && this.#cursor > 0) {
|
||||
this.#input = this.#input.slice(0, this.#cursor - 1) + this.#input.slice(this.#cursor);
|
||||
this.#cursor--;
|
||||
this.#selectedIndex = 0;
|
||||
this.#updateResults();
|
||||
return;
|
||||
}
|
||||
// Printable character
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32 && data !== "\x7f") {
|
||||
this.#input = this.#input.slice(0, this.#cursor) + data + this.#input.slice(this.#cursor);
|
||||
this.#cursor++;
|
||||
this.#selectedIndex = 0;
|
||||
this.#updateResults();
|
||||
}
|
||||
}
|
||||
|
||||
render(_width: number): readonly string[] {
|
||||
const w = OVERLAY_WIDTH;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(topBorder(w, "Move to directory"));
|
||||
lines.push(row(this.#renderInput(), w));
|
||||
lines.push(row("", w));
|
||||
|
||||
if (this.#results.length === 0 && this.#input.length > 0) {
|
||||
lines.push(row(theme.fg("dim", "No matching directories"), w));
|
||||
} else {
|
||||
for (let i = 0; i < Math.min(this.#results.length, MAX_RESULTS); i++) {
|
||||
const item = this.#results[i]!;
|
||||
const selected = i === this.#selectedIndex;
|
||||
const marker = selected ? theme.fg("accent", "▶ ") : " ";
|
||||
const label = selected ? theme.fg("accent", item.label) : theme.fg("text", item.label);
|
||||
lines.push(row(`${marker}${label}`, w));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(row("", w));
|
||||
lines.push(row(theme.fg("dim", "Type to filter · ↑↓ navigate · Tab accept · Enter confirm · Esc cancel"), w));
|
||||
lines.push(bottomBorder(w));
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
#renderInput(): string {
|
||||
const prompt = theme.fg("dim", "Path: ");
|
||||
if (this.#input.length === 0) {
|
||||
const placeholder = theme.fg("dim", "Type a directory path…");
|
||||
const marker = this.#focused ? CURSOR_MARKER : "";
|
||||
return `${prompt}${placeholder}${marker}\x1b[7m \x1b[27m`;
|
||||
}
|
||||
const before = this.#input.slice(0, this.#cursor);
|
||||
const cursorChar = this.#cursor < this.#input.length ? this.#input[this.#cursor] : " ";
|
||||
const after = this.#input.slice(this.#cursor + 1);
|
||||
const marker = this.#focused ? CURSOR_MARKER : "";
|
||||
return `${prompt}${before}${marker}\x1b[7m${cursorChar}\x1b[27m${after}`;
|
||||
}
|
||||
|
||||
#updateResults(): void {
|
||||
this.#results = searchDirectories(this.#input, this.#cwd, MAX_RESULTS + 5);
|
||||
if (this.#selectedIndex >= this.#results.length) {
|
||||
this.#selectedIndex = Math.max(0, this.#results.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
#confirm(): void {
|
||||
const selected = this.#results[this.#selectedIndex];
|
||||
if (selected) {
|
||||
this.#done({ directory: selected.value });
|
||||
return;
|
||||
}
|
||||
if (this.#input.trim().length > 0) {
|
||||
this.#done({ directory: this.#input.trim() });
|
||||
return;
|
||||
}
|
||||
this.#done(undefined);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { BashExecutionComponent } from "../../modes/components/bash-execution";
|
||||
import { BorderedLoader } from "../../modes/components/bordered-loader";
|
||||
import { DynamicBorder } from "../../modes/components/dynamic-border";
|
||||
import { EvalExecutionComponent } from "../../modes/components/eval-execution";
|
||||
import { MoveOverlay, type MoveOverlayResult } from "../../modes/components/move-overlay";
|
||||
import { TranscriptBlock } from "../../modes/components/transcript-container";
|
||||
import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme";
|
||||
import type { InteractiveModeContext } from "../../modes/types";
|
||||
@@ -38,9 +39,11 @@ import { computeContextBreakdown, renderContextUsage } from "../../modes/utils/c
|
||||
import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
|
||||
import { buildToolsMarkdown } from "../../modes/utils/tools-markdown";
|
||||
import type { AsyncJobSnapshotItem } from "../../session/agent-session";
|
||||
import { markMoveSession } from "../../session/agent-session";
|
||||
import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage";
|
||||
import type { CompactMode } from "../../session/compact-modes";
|
||||
import type { NewSessionOptions } from "../../session/session-entries";
|
||||
import { SessionManager } from "../../session/session-manager";
|
||||
import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
|
||||
import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
|
||||
import { outputMeta } from "../../tools/output-meta";
|
||||
@@ -911,13 +914,34 @@ export class CommandController {
|
||||
]);
|
||||
}
|
||||
|
||||
async handleMoveCommand(targetPath: string): Promise<void> {
|
||||
/**
|
||||
* `/move` — switch to a fresh empty session in a different directory.
|
||||
*
|
||||
* With no `targetPath` (TUI only), opens an autocomplete overlay so the user
|
||||
* can pick or type a directory. With a `targetPath`, resolves it directly.
|
||||
* If the target directory does not exist, the user is asked whether to create
|
||||
* it. A brand-new empty session is then started in the target directory and
|
||||
* the current session is left behind (resumable via `/resume`).
|
||||
*/
|
||||
async handleMoveCommand(targetPath?: string): Promise<void> {
|
||||
if (this.ctx.session.isStreaming) {
|
||||
this.ctx.showWarning("Wait for the current response to finish or abort it before moving.");
|
||||
return;
|
||||
}
|
||||
|
||||
const unquoted = stripOuterDoubleQuotes(targetPath);
|
||||
let input: string | undefined = targetPath?.trim() || undefined;
|
||||
|
||||
// No argument in TUI mode: open the path autocomplete overlay.
|
||||
if (!input) {
|
||||
const result = await this.ctx.showHookCustom<MoveOverlayResult | undefined>(
|
||||
(_tui, _theme, _keybindings, done) => new MoveOverlay(this.ctx.sessionManager.getCwd(), done),
|
||||
{ overlay: true },
|
||||
);
|
||||
if (!result) return; // cancelled
|
||||
input = result.directory;
|
||||
}
|
||||
|
||||
const unquoted = stripOuterDoubleQuotes(input);
|
||||
if (!unquoted) {
|
||||
this.ctx.showError("Usage: /move <path>");
|
||||
return;
|
||||
@@ -926,25 +950,64 @@ export class CommandController {
|
||||
const cwd = this.ctx.sessionManager.getCwd();
|
||||
const resolvedPath = resolveToCwd(unquoted, cwd);
|
||||
|
||||
// If the directory doesn't exist, offer to create it.
|
||||
let isDirectory: boolean;
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
this.ctx.showError(`Not a directory: ${resolvedPath}`);
|
||||
isDirectory = (await fs.stat(resolvedPath)).isDirectory();
|
||||
} catch {
|
||||
isDirectory = false;
|
||||
}
|
||||
|
||||
if (!isDirectory) {
|
||||
const parentDir = path.dirname(resolvedPath);
|
||||
let parentExists = false;
|
||||
try {
|
||||
parentExists = (await fs.stat(parentDir)).isDirectory();
|
||||
} catch {
|
||||
parentExists = false;
|
||||
}
|
||||
if (!parentExists) {
|
||||
this.ctx.showError(`Cannot create "${path.basename(resolvedPath)}": parent directory does not exist`);
|
||||
return;
|
||||
}
|
||||
const confirmed = await this.ctx.showHookConfirm(
|
||||
"Create directory?",
|
||||
`"${path.basename(resolvedPath)}" does not exist. Create it?`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await fs.mkdir(resolvedPath, { recursive: true });
|
||||
} catch (err) {
|
||||
this.ctx.showError(`Failed to create directory: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
this.ctx.showError(`Directory does not exist: ${resolvedPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ctx.sessionManager.flush();
|
||||
await this.ctx.sessionManager.moveTo(resolvedPath);
|
||||
// Create a fresh empty session file in the target directory's session
|
||||
// folder, then switch to it. The current session is left behind and
|
||||
// remains resumable via /resume.
|
||||
const newSessionFile = SessionManager.createEmptySessionFile(resolvedPath);
|
||||
await this.ctx.session.switchSession(newSessionFile);
|
||||
markMoveSession(newSessionFile);
|
||||
await this.ctx.applyCwdChange(resolvedPath);
|
||||
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.pendingMessagesContainer.clear();
|
||||
this.ctx.compactionQueuedMessages = [];
|
||||
this.ctx.streamingComponent = undefined;
|
||||
this.ctx.streamingMessage = undefined;
|
||||
this.ctx.pendingTools.clear();
|
||||
this.ctx.statusLine.invalidate();
|
||||
this.ctx.statusLine.setSessionStartTime(Date.now());
|
||||
this.ctx.updateEditorTopBorder();
|
||||
this.ctx.updateEditorBorderColor();
|
||||
await this.ctx.reloadTodos();
|
||||
this.ctx.ui.requestRender(true, { clearScrollback: true });
|
||||
|
||||
this.ctx.present([
|
||||
new Spacer(1),
|
||||
new Text(`${theme.fg("accent", `${theme.status.success} Session moved to ${resolvedPath}`)}`, 1, 1),
|
||||
new Text(`${theme.fg("accent", `${theme.status.success} Moved to ${resolvedPath}`)}`, 1, 1),
|
||||
]);
|
||||
} catch (err) {
|
||||
this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
|
||||
@@ -3689,7 +3689,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
return this.#commandController.handleForkCommand();
|
||||
}
|
||||
|
||||
handleMoveCommand(targetPath: string): Promise<void> {
|
||||
handleMoveCommand(targetPath?: string): Promise<void> {
|
||||
return this.#commandController.handleMoveCommand(targetPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@ export interface InteractiveModeContext {
|
||||
handleCompactCommand(customInstructions?: string, mode?: CompactMode): Promise<CompactionOutcome>;
|
||||
handleHandoffCommand(customInstructions?: string): Promise<void>;
|
||||
handleShakeCommand(mode: ShakeMode): Promise<void>;
|
||||
handleMoveCommand(targetPath: string): Promise<void>;
|
||||
handleMoveCommand(targetPath?: string): Promise<void>;
|
||||
handleRenameCommand(title: string): Promise<void>;
|
||||
handleMemoryCommand(text: string): Promise<void>;
|
||||
handleSTTToggle(): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user