Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Clickable chat symbols #2975

Merged
merged 19 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { GlobalContext } from "./util/GlobalContext";
import historyManager from "./util/history";
import { editConfigJson, setupInitialDotContinueDirectory } from "./util/paths";
import { Telemetry } from "./util/posthog";
import { getSymbolsForManyFiles } from "./util/treeSitter";
import { TTS } from "./util/tts";

import type { ContextItemId, IDE, IndexingProgressUpdate } from ".";
Expand Down Expand Up @@ -365,6 +366,11 @@ export class Core {
}
});

on("context/getSymbolsForFiles", async (msg) => {
const { uris } = msg.data;
return await getSymbolsForManyFiles(uris, this.ide);
});

on("config/getSerializedProfileInfo", async (msg) => {
return {
config: await this.configHandler.getSerializedConfig(),
Expand Down
9 changes: 8 additions & 1 deletion core/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Parser from "web-tree-sitter";
import { GetGhTokenArgs } from "./protocol/ide";

declare global {
Expand Down Expand Up @@ -311,13 +312,19 @@ export interface InputModifiers {
noContext: boolean;
}

export interface SymbolWithRange extends RangeInFile {
name: string;
type: Parser.SyntaxNode["type"];
}

export type FileSymbolMap = Record<string, SymbolWithRange[]>;

export interface PromptLog {
modelTitle: string;
completionOptions: CompletionOptions;
prompt: string;
completion: string;
}

export interface ChatHistoryItem {
message: ChatMessage;
editorState?: any;
Expand Down
11 changes: 9 additions & 2 deletions core/llm/llms/Bedrock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import {
import { stripImages } from "../images.js";
import { BaseLLM } from "../index.js";

/**
* Bedrock class implements AWS Bedrock LLM integration.
* It handles streaming completions and chat messages using AWS Bedrock runtime.
* Supports both text and image inputs through Claude 3 models.
*/
class Bedrock extends BaseLLM {
static providerName: ModelProvider = "bedrock";
static defaultOptions: Partial<LLMOptions> = {
Expand Down Expand Up @@ -122,7 +127,9 @@ class Bedrock extends BaseLLM {
// TODO: Additionally, consider implementing a global exception handler for the providers to give users clearer feedback.
// For example, differentiate between client-side errors (4XX status codes) and server-side issues (5XX status codes),
// providing meaningful error messages to improve the user experience.
stopSequences: options.stop?.filter((stop) => stop.trim() !== "").slice(0, 4),
stopSequences: options.stop
?.filter((stop) => stop.trim() !== "")
.slice(0, 4),
},
};
}
Expand Down Expand Up @@ -164,7 +171,7 @@ class Bedrock extends BaseLLM {
try {
return await fromIni({
profile: this.profile,
ignoreCache: true
ignoreCache: true,
})();
} catch (e) {
console.warn(
Expand Down
2 changes: 2 additions & 0 deletions core/protocol/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ContextItemWithId,
ContextSubmenuItem,
DiffLine,
FileSymbolMap,
IdeSettings,
LLMFullCompletionOptions,
MessageContent,
Expand Down Expand Up @@ -76,6 +77,7 @@ export type ToCoreFromIdeOrWebviewProtocol = {
},
ContextItemWithId[],
];
"context/getSymbolsForFiles": [{ uris: string[] }, FileSymbolMap];
"context/loadSubmenuItems": [{ title: string }, ContextSubmenuItem[]];
"autocomplete/complete": [AutocompleteInput, string[]];
"context/addDocs": [SiteIndexingConfig, void];
Expand Down
1 change: 1 addition & 0 deletions core/protocol/passThrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const WEBVIEW_TO_CORE_PASS_THROUGH: (keyof ToCoreFromWebviewProtocol)[] =
"config/deleteModel",
"config/reload",
"context/getContextItems",
"context/getSymbolsForFiles",
"context/loadSubmenuItems",
"context/addDocs",
"context/removeDocs",
Expand Down
91 changes: 91 additions & 0 deletions core/util/treeSitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "node:fs";
import * as path from "node:path";

import Parser, { Language } from "web-tree-sitter";
import { FileSymbolMap, IDE, SymbolWithRange } from "..";

export enum LanguageName {
CPP = "cpp",
Expand Down Expand Up @@ -205,3 +206,93 @@ async function loadLanguageForFileExt(
);
return await Parser.Language.load(wasmPath);
}

// See https://tree-sitter.github.io/tree-sitter/using-parsers
const GET_SYMBOLS_FOR_NODE_TYPES: Parser.SyntaxNode["type"][] = [
"class_declaration",
"class_definition",
"function_item", // function name = first "identifier" child
"function_definition",
"method_declaration", // method name = first "identifier" child
"method_definition",
"generator_function_declaration",
// property_identifier
// field_declaration
// "arrow_function",
];

export async function getSymbolsForFile(
filepath: string,
contents: string,
): Promise<SymbolWithRange[] | undefined> {
const parser = await getParserForFile(filepath);

if (!parser) {
return;
}

const tree = parser.parse(contents);
// console.log(`file: ${filepath}`);

// Function to recursively find all named nodes (classes and functions)
const symbols: SymbolWithRange[] = [];
function findNamedNodesRecursive(node: Parser.SyntaxNode) {
// console.log(`node: ${node.type}, ${node.text}`);
if (GET_SYMBOLS_FOR_NODE_TYPES.includes(node.type)) {
// console.log(`parent: ${node.type}, ${node.text.substring(0, 200)}`);
// node.children.forEach((child) => {
// console.log(`child: ${child.type}, ${child.text}`);
// });

// Empirically, the actual name is the last identifier in the node
// Especially with languages where return type is declared before the name
// TODO use findLast in newer version of node target
let identifier: Parser.SyntaxNode | undefined = undefined;
for (let i = node.children.length - 1; i >= 0; i--) {
if (
node.children[i].type === "identifier" ||
node.children[i].type === "property_identifier"
) {
identifier = node.children[i];
break;
}
}

if (identifier?.text) {
symbols.push({
filepath,
type: node.type,
name: identifier.text,
range: {
start: {
character: node.startPosition.column,
line: node.startPosition.row,
},
end: {
character: node.endPosition.column + 1,
line: node.endPosition.row + 1,
},
},
});
}
}
node.children.forEach(findNamedNodesRecursive);
}
findNamedNodesRecursive(tree.rootNode);

return symbols;
}

export async function getSymbolsForManyFiles(
uris: string[],
ide: IDE,
): Promise<FileSymbolMap> {
const filesAndSymbols = await Promise.all(
uris.map(async (uri): Promise<[string, SymbolWithRange[]]> => {
const contents = await ide.readFile(uri);
const symbols = await getSymbolsForFile(uri, contents);
return [uri, symbols ?? []];
}),
);
return Object.fromEntries(filesAndSymbols);
}
13 changes: 10 additions & 3 deletions gui/src/components/History/HistoryTableRow.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { PencilSquareIcon, TrashIcon } from "@heroicons/react/24/outline";
import { SessionInfo } from "core";
import React, { useState } from "react";
import { useDispatch } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { Input } from "..";
import useHistory from "../../hooks/useHistory";
import HeaderButtonWithToolTip from "../gui/HeaderButtonWithToolTip";
import { RootState } from "../../redux/store";

function lastPartOfPath(path: string): string {
const sep = path.includes("/") ? "/" : "\\";
Expand All @@ -29,6 +30,9 @@ export function HistoryTableRow({
const [sessionTitleEditValue, setSessionTitleEditValue] = useState(
session.title,
);
const currentSessionId = useSelector(
(state: RootState) => state.state.sessionId,
);

const { saveSession, deleteSession, loadSession, getSession, updateSession } =
useHistory(dispatch);
Expand Down Expand Up @@ -59,8 +63,11 @@ export function HistoryTableRow({
className="hover:bg-vsc-editor-background relative box-border flex max-w-full cursor-pointer overflow-hidden rounded-lg p-3"
onClick={async () => {
// Save current session
await saveSession();
await loadSession(session.sessionId);
if (session.sessionId !== currentSessionId) {
await saveSession();
await loadSession(session.sessionId);
}

navigate("/");
}}
>
Expand Down
1 change: 1 addition & 0 deletions gui/src/components/StepContainer/StepContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export default function StepContainer(props: StepContainerProps) {
<StyledMarkdownPreview
isRenderingInStepContainer
source={stripImages(props.item.message.content)}
itemIndex={props.index}
/>
)}
</ContentDiv>
Expand Down
4 changes: 1 addition & 3 deletions gui/src/components/mainInput/CodeBlockComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { NodeViewWrapper } from "@tiptap/react";
import { ContextItemWithId } from "core";
import { vscBadgeBackground } from "..";
import CodeSnippetPreview from "../markdown/CodeSnippetPreview";
import { useSelector } from "react-redux";
import { RootState } from "../../redux/store";

export const CodeBlockComponent = (props: any) => {
const { node, deleteNode, selected, editor, updateAttributes } = props;
Expand All @@ -13,7 +11,7 @@ export const CodeBlockComponent = (props: any) => {
// store.state.history[store.state.history.length - 1].contextItems,
// );
// const isFirstContextItem = item.id === contextItems[0]?.id;
const isFirstContextItem = false;
const isFirstContextItem = false; // TODO: fix this, decided not worth the insane renders for now

return (
<NodeViewWrapper className="code-block-with-content" as="p">
Expand Down
19 changes: 15 additions & 4 deletions gui/src/components/mainInput/ContextItemsPeek.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import SafeImg from "../SafeImg";
import { INSTRUCTIONS_BASE_ITEM } from "core/context/providers/utils";
import { getIconFromDropdownItem } from "./MentionList";
import { getBasename } from "core/util";
import { RootState } from "../../redux/store";
import { useSelector } from "react-redux";

interface ContextItemsPeekProps {
contextItems?: ContextItemWithId[];
isGatheringContext: boolean;
isCurrentContextPeek: boolean;
}

interface ContextItemsPeekItemProps {
Expand Down Expand Up @@ -118,15 +120,24 @@ function ContextItemsPeekItem({ contextItem }: ContextItemsPeekItemProps) {

function ContextItemsPeek({
contextItems,
isGatheringContext,
isCurrentContextPeek,
}: ContextItemsPeekProps) {
const [open, setOpen] = useState(false);

const ctxItems = contextItems?.filter(
(ctxItem) => !ctxItem.name.includes(INSTRUCTIONS_BASE_ITEM.name),
);

if ((!ctxItems || ctxItems.length === 0) && !isGatheringContext) {
const isGatheringContext = useSelector(
(store: RootState) => store.state.context.isGathering,
);
const gatheringMessage = useSelector(
(store: RootState) => store.state.context.gatheringMessage,
);

const indicateIsGathering = isCurrentContextPeek && isGatheringContext;

if ((!ctxItems || ctxItems.length === 0) && !indicateIsGathering) {
return null;
}

Expand All @@ -151,7 +162,7 @@ function ContextItemsPeek({
<span className="ml-1 text-xs text-gray-400 transition-colors duration-200">
{isGatheringContext ? (
<>
Gathering context
{gatheringMessage}
<AnimatedEllipsis />
</>
) : (
Expand Down
25 changes: 1 addition & 24 deletions gui/src/components/mainInput/ContinueInputBox.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Editor, JSONContent } from "@tiptap/react";
import { ContextItemWithId, InputModifiers } from "core";
import { useEffect, useRef, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import styled, { keyframes } from "styled-components";
import { defaultBorderRadius, vscBackground } from "..";
Expand Down Expand Up @@ -69,12 +68,6 @@ function ContinueInputBox(props: ContinueInputBoxProps) {
const availableContextProviders = useSelector(
(store: RootState) => store.state.config.contextProviders,
);
const isGatheringContextStore = useSelector(
(store: RootState) => store.state.isGatheringContext,
);

const [isGatheringContext, setIsGatheringContext] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);

useWebviewListener(
"newSessionWithPrompt",
Expand All @@ -92,22 +85,6 @@ function ContinueInputBox(props: ContinueInputBoxProps) {
[props.isMainInput],
);

useEffect(() => {
if (isGatheringContextStore && !isGatheringContext) {
// 500ms delay when going from false -> true to prevent flashing loading indicator
timeoutRef.current = setTimeout(() => setIsGatheringContext(true), 500);
} else {
// Update immediately otherwise (i.e. true -> false)
setIsGatheringContext(isGatheringContextStore);
}

return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [isGatheringContextStore]);

return (
<div className={`mb-1 ${props.hidden ? "hidden" : ""}`}>
<div className={`relative flex px-2`}>
Expand Down Expand Up @@ -135,7 +112,7 @@ function ContinueInputBox(props: ContinueInputBoxProps) {
</div>
<ContextItemsPeek
contextItems={props.contextItems}
isGatheringContext={isGatheringContext && props.isLastUserInput}
isCurrentContextPeek={props.isLastUserInput}
/>
</div>
);
Expand Down
Loading