Archived
Align web at-space completion with TUI
This commit is contained in:
@@ -103,7 +103,7 @@ export const api = {
|
|||||||
messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage),
|
messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage),
|
||||||
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
|
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
|
||||||
commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
|
commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
|
||||||
files: (cwd: string, query: string, kind?: FileSuggestion["kind"]) => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}`, arrayOf(parseFileSuggestion)),
|
files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)),
|
||||||
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp") => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
|
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp") => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
|
||||||
shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
|
shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
|
||||||
runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
|
runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
|
||||||
|
|||||||
@@ -103,21 +103,22 @@ export class PromptEditor extends LitElement {
|
|||||||
...(command.description === undefined ? {} : { description: command.description }),
|
...(command.description === undefined ? {} : { description: command.description }),
|
||||||
}));
|
}));
|
||||||
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
|
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
|
||||||
const files = await api.files(this.cwd, trigger.query, trigger.fileKind).catch(emptyFileSuggestions);
|
const files = await api.files(this.cwd, trigger.query, trigger.fileKind, trigger.fileMode).catch(emptyFileSuggestions);
|
||||||
if (version !== this.requestVersion) return;
|
if (version !== this.requestVersion) return;
|
||||||
this.completions = files
|
this.completions = files
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
.map((file) => ({ kind: "file", replaceFrom: trigger.from, replaceTo: trigger.to, insertText: `@${file.path}`, detail: file.kind }));
|
.map((file) => ({ kind: "file", replaceFrom: trigger.from, replaceTo: trigger.to, insertText: `${trigger.fileMode === "path" ? "" : "@"}${file.path}`, detail: file.kind }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileKind?: FileSuggestion["kind"] } | undefined {
|
private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileKind?: FileSuggestion["kind"]; fileMode?: "file" | "path" } | undefined {
|
||||||
const cursor = this.textarea?.selectionStart ?? this.draft.length;
|
const cursor = this.textarea?.selectionStart ?? this.draft.length;
|
||||||
const beforeCursor = this.draft.slice(0, cursor);
|
const beforeCursor = this.draft.slice(0, cursor);
|
||||||
if (beforeCursor.endsWith("@ ")) return { kind: "file", query: "", from: beforeCursor.length - 2, to: cursor, fileKind: "untracked" };
|
|
||||||
|
|
||||||
const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1;
|
const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1;
|
||||||
const token = beforeCursor.slice(tokenStart);
|
const token = beforeCursor.slice(tokenStart);
|
||||||
|
const beforeToken = beforeCursor.slice(0, tokenStart);
|
||||||
|
if (beforeToken.endsWith("@ ")) return { kind: "file", query: token, from: tokenStart, to: cursor, fileMode: "path" };
|
||||||
if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor };
|
if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor };
|
||||||
if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor };
|
if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor };
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -154,7 +155,8 @@ export class PromptEditor extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private pick(item: CompletionItem) {
|
private pick(item: CompletionItem) {
|
||||||
this.draft = `${this.draft.slice(0, item.replaceFrom)}${item.insertText} ${this.draft.slice(item.replaceTo)}`;
|
const suffix = item.kind === "file" && item.insertText.endsWith("/") ? "" : " ";
|
||||||
|
this.draft = `${this.draft.slice(0, item.replaceFrom)}${item.insertText}${suffix}${this.draft.slice(item.replaceTo)}`;
|
||||||
if (this.sessionId !== undefined && this.sessionId !== "") saveDraft(this.sessionId, this.draft);
|
if (this.sessionId !== undefined && this.sessionId !== "") saveDraft(this.sessionId, this.draft);
|
||||||
this.completions = [];
|
this.completions = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function inputModeForDraft(draft: string): InputMode {
|
|||||||
const trimmed = draft.trimStart();
|
const trimmed = draft.trimStart();
|
||||||
if (trimmed.startsWith("!")) return { kind: "shell", excludeFromContext: trimmed.startsWith("!!") };
|
if (trimmed.startsWith("!")) return { kind: "shell", excludeFromContext: trimmed.startsWith("!!") };
|
||||||
if (currentToken(draft).startsWith("/")) return { kind: "command" };
|
if (currentToken(draft).startsWith("/")) return { kind: "command" };
|
||||||
if (draft.endsWith("@ ") || currentToken(draft).startsWith("@")) return { kind: "file" };
|
if (isFileCompletionContext(draft)) return { kind: "file" };
|
||||||
return { kind: "normal" };
|
return { kind: "normal" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,3 +20,10 @@ function currentToken(draft: string): string {
|
|||||||
const tokenStart = Math.max(draft.lastIndexOf(" "), draft.lastIndexOf("\n")) + 1;
|
const tokenStart = Math.max(draft.lastIndexOf(" "), draft.lastIndexOf("\n")) + 1;
|
||||||
return draft.slice(tokenStart);
|
return draft.slice(tokenStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFileCompletionContext(draft: string): boolean {
|
||||||
|
const token = currentToken(draft);
|
||||||
|
if (token.startsWith("@")) return true;
|
||||||
|
const tokenStart = draft.length - token.length;
|
||||||
|
return draft.slice(0, tokenStart).endsWith("@ ");
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -6,7 +6,7 @@ import fastifyWebsocket from "@fastify/websocket";
|
|||||||
import { ProjectStore } from "./storage/projectStore.js";
|
import { ProjectStore } from "./storage/projectStore.js";
|
||||||
import { ProjectService } from "./projects/projectService.js";
|
import { ProjectService } from "./projects/projectService.js";
|
||||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||||
import { listFileSuggestions } from "./workspaces/fileSuggestions.js";
|
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||||
import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js";
|
import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js";
|
||||||
|
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true });
|
||||||
@@ -36,9 +36,10 @@ app.get<{ Params: { projectId: string } }>("/api/projects/:projectId/workspaces"
|
|||||||
|
|
||||||
registerSessionProxyRoutes(app);
|
registerSessionProxyRoutes(app);
|
||||||
|
|
||||||
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other" } }>("/api/files", async (request, reply) => {
|
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>("/api/files", async (request, reply) => {
|
||||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||||
try {
|
try {
|
||||||
|
if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? "");
|
||||||
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind);
|
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
|
import { readdir, stat } from "node:fs/promises";
|
||||||
|
import { basename, dirname, join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import type { ClientFileSuggestion } from "../types.js";
|
import type { ClientFileSuggestion } from "../types.js";
|
||||||
|
|
||||||
@@ -14,6 +16,29 @@ export async function listFileSuggestions(cwd: string, query = "", kind?: Client
|
|||||||
.slice(0, 80);
|
.slice(0, 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listPathSuggestions(cwd: string, prefix = ""): Promise<ClientFileSuggestion[]> {
|
||||||
|
const normalizedPrefix = prefix.replace(/^@/, "").replace(/\\/g, "/");
|
||||||
|
const directoryPrefix = normalizedPrefix.endsWith("/") ? normalizedPrefix : dirname(normalizedPrefix) === "." ? "" : `${dirname(normalizedPrefix)}/`;
|
||||||
|
const searchPrefix = normalizedPrefix.endsWith("/") ? "" : basename(normalizedPrefix);
|
||||||
|
const entries = await readdir(join(cwd, directoryPrefix), { withFileTypes: true });
|
||||||
|
const suggestions: ClientFileSuggestion[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) continue;
|
||||||
|
let isDirectory = entry.isDirectory();
|
||||||
|
if (!isDirectory && entry.isSymbolicLink()) {
|
||||||
|
try {
|
||||||
|
isDirectory = (await stat(join(cwd, directoryPrefix, entry.name))).isDirectory();
|
||||||
|
} catch {
|
||||||
|
isDirectory = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suggestions.push({ path: `${directoryPrefix}${entry.name}${isDirectory ? "/" : ""}`, kind: "other" });
|
||||||
|
}
|
||||||
|
return suggestions
|
||||||
|
.sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path))
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
async function listGitFiles(cwd: string): Promise<ClientFileSuggestion[]> {
|
async function listGitFiles(cwd: string): Promise<ClientFileSuggestion[]> {
|
||||||
const [tracked, untracked] = await Promise.all([
|
const [tracked, untracked] = await Promise.all([
|
||||||
git(cwd, ["ls-files"]),
|
git(cwd, ["ls-files"]),
|
||||||
|
|||||||
Reference in New Issue
Block a user