Skip to content

Commit

Permalink
Proposal for an API based "Suggestions" detail in infoview (#312)
Browse files Browse the repository at this point in the history
* first version

* fix formatting mishap

* fix import

* Update infoview/suggestions.tsx

Co-authored-by: Gabriel Ebner <[email protected]>

* Update infoview/suggestions.tsx

Co-authored-by: Gabriel Ebner <[email protected]>

* insert tactic into editor

* rm props.widget

* add dosuggest icon

* handle unset suggestionURL

* suggestions detail open when suggestions activated

* debounce prefix

* Update infoview/info.tsx

Co-authored-by: Gabriel Ebner <[email protected]>

* Update src/infoview.ts

Co-authored-by: Gabriel Ebner <[email protected]>

* Update infoview/info.tsx

Co-authored-by: Gabriel Ebner <[email protected]>

* Update infoview/suggestions.tsx

Co-authored-by: Gabriel Ebner <[email protected]>

* typo

* Update infoview/suggestions.tsx

Co-authored-by: Gabriel Ebner <[email protected]>

* Update infoview/suggestions.tsx

Co-authored-by: Gabriel Ebner <[email protected]>

* Update infoview/suggestions.tsx

Co-authored-by: Gabriel Ebner <[email protected]>

* Update infoview/suggestions.tsx

Co-authored-by: Gabriel Ebner <[email protected]>

* x-api-key instead of api_key in message

Co-authored-by: Gabriel Ebner <[email protected]>
  • Loading branch information
timlacroix and gebner authored Sep 14, 2022
1 parent 8ca7fa6 commit d62eece
Show file tree
Hide file tree
Showing 8 changed files with 302 additions and 29 deletions.
28 changes: 8 additions & 20 deletions infoview/info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { LocationContext, ConfigContext } from '.';
import { Widget } from './widget';
import { Goal } from './goal';
import { Messages, processMessages, ProcessedMessage, GetMessagesFor } from './messages';
import { basename, useEvent } from './util';
import { CopyToCommentIcon, PinnedIcon, PinIcon, ContinueIcon, PauseIcon, RefreshIcon, GoToFileIcon } from './svg_icons';
import { basename, useEvent, delayedThrottled } from './util';
import { CopyToCommentIcon, PinnedIcon, PinIcon, ContinueIcon, PauseIcon, RefreshIcon, GoToFileIcon, DoSuggestIcon, DoNotSuggestIcon } from './svg_icons';
import { Details } from './collapsing';
import { Event, InfoResponse, CurrentTasksResponse, Message } from 'lean-client-js-core';
import { Suggestor } from './suggestions';

/** Older versions of Lean can't deal with multiple simul info requests so this just prevents that. */
class OneAtATimeDispatcher {
Expand Down Expand Up @@ -63,24 +64,6 @@ function useMappedEvent<T, S>(ev: Event<T>, initial: S, f: (_: T) => S, deps?: R
return s;
}

// returns function that triggers `cb`
// - but only ms milliseconds after the first call
// - and not more often than once every ms milliseconds
function delayedThrottled(ms: number, cb: () => void): () => void {
const waiting = React.useRef<boolean>(false);
const callbackRef = React.useRef<() => void>();
callbackRef.current = cb;
return () => {
if (!waiting.current) {
waiting.current = true;
setTimeout(() => {
waiting.current = false;
callbackRef.current();
}, ms);
}
};
}

interface InfoState {
loc: Location;
loading: boolean;
Expand Down Expand Up @@ -144,6 +127,7 @@ export function Info(props: InfoProps): JSX.Element {
const {isCursor, isPinned, onPin} = props;

const [isPaused, setPaused] = React.useState<boolean>(false);
const [doSuggest, setDoSuggest] = React.useState<boolean>(false);
const isCurrentlyPaused = React.useRef<boolean>();
isCurrentlyPaused.current = isPaused;

Expand Down Expand Up @@ -194,6 +178,7 @@ export function Info(props: InfoProps): JSX.Element {
<a className="link pointer mh2 dim" onClick={e => { e.preventDefault(); onPin(!isPinned)}} title={isPinned ? 'unpin' : 'pin'}>{isPinned ? <PinnedIcon/> : <PinIcon/>}</a>
<a className="link pointer mh2 dim" onClick={e => { e.preventDefault(); setPaused(!isPaused)}} title={isPaused ? 'continue updating' : 'pause updating'}>{isPaused ? <ContinueIcon/> : <PauseIcon/>}</a>
{ !isPaused && <a className={'link pointer mh2 dim'} onClick={e => { e.preventDefault(); forceUpdate(); }} title="update"><RefreshIcon/></a> }
<a className="link pointer mh2 dim" onClick={e => { e.preventDefault(); setDoSuggest(!doSuggest)}} title={doSuggest ? 'stop ML suggestions' : 'activate ML suggestions'}>{doSuggest ? <DoNotSuggestIcon/> : <DoSuggestIcon/>}</a>
</span>
</summary>
<div className="ml1">
Expand Down Expand Up @@ -225,6 +210,9 @@ export function Info(props: InfoProps): JSX.Element {
</div>
</Details>
</div>
{(doSuggest && goalState) ? <div>
<Suggestor widget={widget} goalState={goalState} />
</div> : null }
{nothingToShow && (
loading ? 'Loading...' :
isPaused ? <span>Updating is paused. <a className="link pointer dim" onClick={e => forceUpdate()}>Refresh</a> or <a className="link pointer dim" onClick={e => setPaused(false)}>resume updating</a> to see information</span> :
Expand Down
9 changes: 8 additions & 1 deletion infoview/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Server, Transport, Connection, Event, TransportError, Message } from 'lean-client-js-core';
import { ToInfoviewMessage, FromInfoviewMessage, Config, Location, defaultConfig, PinnedLocation, InsertTextMessage } from '../src/shared';
import {
ToInfoviewMessage, FromInfoviewMessage, Config, Location, defaultConfig, PinnedLocation, InsertTextMessage,
SuggestionsErrorInt, SuggestionsInt
} from '../src/shared';
declare const acquireVsCodeApi;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const vscode = acquireVsCodeApi();
Expand Down Expand Up @@ -45,6 +48,8 @@ export const TogglePinEvent: Event<unknown> = new Event();
export const ServerRestartEvent: Event<unknown> = new Event();
export const AllMessagesEvent: Event<Message[]> = new Event();
export const ToggleAllMessagesEvent: Event<unknown> = new Event();
export const SuggestionsErrorEvent: Event<SuggestionsErrorInt> = new Event();
export const SuggestionsEvent: Event<SuggestionsInt> = new Event();

export let currentAllMessages: Message[] = [];
AllMessagesEvent.on((msgs) => currentAllMessages = msgs);
Expand All @@ -69,6 +74,8 @@ window.addEventListener('message', event => { // messages from the extension
case 'toggle_all_messages': ToggleAllMessagesEvent.fire({}); break;
case 'server_event': break;
case 'server_error': break;
case 'Suggestions_error': SuggestionsErrorEvent.fire(message); break;
case 'Suggestions': SuggestionsEvent.fire(message); break;
}
});

Expand Down
149 changes: 149 additions & 0 deletions infoview/suggestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import * as React from 'react';
import { WidgetIdentifier } from 'lean-client-js-core';
import { SuggestionsInfoStatus, SuggestionsInt, SuggestionsModelResult, SuggestionsTacticInfo } from '../src/shared';
import { useEvent, delayedThrottled } from './util';
import { SuggestionsEvent, SuggestionsErrorEvent, post } from './server';

const statusColTable: { [T in SuggestionsInfoStatus]: string } = {
'loading': 'gold',
'error': 'dark-red',
'valid': 'green',
'solving': 'blue',
'done': ''
}
interface SuggestionsProps {
widget: WidgetIdentifier;
goalState: string;
}

function SingleTacticInfo(props: { tacticInfo: SuggestionsTacticInfo, widget: WidgetIdentifier}): JSX.Element {
return (
<a className={'font-code link pointer mh2 glow easeTransition ' + statusColTable.done} style={{whiteSpace: 'pre-wrap'}} key={`${props.tacticInfo.tactic}`}
onClick={
e => {
e.preventDefault();
post({
command: 'insert_text',
text: `${props.tacticInfo.tactic},`,
insert_type: 'relative'
})
}}
>
{props.tacticInfo.tactic}
</a>
)
}


function Waiter(): JSX.Element {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
const interval = setInterval(() => setCount((count + 1) % 4), 500);
return () => clearInterval(interval);
}, [count]);
return <span>{'.'.repeat(count)}</span>;
}

function Suggestions(props: {
goalState: string, reqId: number, widget: WidgetIdentifier
}) {
const [status, setStatus] = React.useState<SuggestionsInfoStatus>('loading');
const [errorMsg, setErrorMsg] = React.useState<string>();
const [suggestionsResults, setSuggestionsResults] = React.useState<SuggestionsModelResult>();

// get Suggestions results - set Suggestions results
useEvent(
SuggestionsEvent,
(suggestions: SuggestionsInt) => {
if (suggestions.results && props.reqId === suggestions.reqId) {
if (suggestions.results.error) {
setErrorMsg(suggestions.results.error);
setStatus('error');
} else {
setSuggestionsResults(suggestions.results);
setStatus('done');
}
}
},
[props.reqId]
);

React.useEffect(() => {
setStatus('loading');
setErrorMsg(undefined); // reset error msg
}, [props.reqId]);

return props.goalState && (
<div>
<div>
{['loading', 'error'].includes(status) ?
<p className={'easeTransition ml3 ' + statusColTable[status]}>
{status === 'loading' ? 'Querying Suggestions' : 'Something went wrong :('}
{status === 'loading'? <Waiter />: null}
</p>
: null}
{errorMsg ? <i className={'easeTransition ml3 ' + statusColTable[status]}>{errorMsg}</i> : null}
{(['done', 'checking'].includes(status) && suggestionsResults && !suggestionsResults.error && suggestionsResults.tactic_infos) ?
<div>{Object.values(suggestionsResults.tactic_infos).map(
tactic_info => <div><SingleTacticInfo key={tactic_info.tactic} tacticInfo={tactic_info} widget={props.widget}/><br/></div>
)}</div>
: null
}
</div>
</div>
);
}

export function Suggestor(props: SuggestionsProps): JSX.Element {
const [suggReqId, setSuggReqId] = React.useState(0);
const [doSuggest, setDoSuggest] = React.useState(false);
const [errorMsg, setErrorMsg] = React.useState<string>();
const [prefix, setPrefix] = React.useState('');

useEvent(
SuggestionsErrorEvent,
(s) => {
setErrorMsg(s.error);
}, []
);

React.useEffect(() => {
setDoSuggest(true);
}, [prefix, props.goalState])
React.useEffect(delayedThrottled(
300,
() => {
if (doSuggest) setDoSuggest(false);
else return;
if (props.goalState !== undefined && props.goalState !== 'no goals') {
setSuggReqId(rid => {
post({
command: 'get_suggestions',
reqId: rid + 1,
goalState: props.goalState,
prefix
});
setErrorMsg(undefined); // reset error msg
return rid + 1
});
}
}
), [prefix, props.goalState, doSuggest])

const inputProps = { style: { display: 'inline' }, type: 'text', size: 12, value: prefix };

return (
<div>
{errorMsg
? <i className={'easeTransition mv2 ' + statusColTable.error}>{errorMsg}</i>
: <div>
<form className='ml2' onSubmit={(e) => e.preventDefault()}>
<span>Tactic suggestions with prefix: </span>
<input {...inputProps} onChange={(e) => setPrefix(e.target.value)} />
</form>
<Suggestions widget={props.widget} goalState={props.goalState} reqId={suggReqId}/>
</div>
}
</div>
);
}
18 changes: 17 additions & 1 deletion infoview/svg_icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,20 @@ export function GoToFileIcon(): JSX.Element {

export function ClippyIcon(): JSX.Element {
return <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M7 13.992H4v-9h8v2h1v-2.5l-.5-.5H11v-1h-1a2 2 0 0 0-4 0H4.94v1H3.5l-.5.5v10l.5.5H7v-1zm0-11.2a1 1 0 0 1 .8-.8 1 1 0 0 1 .58.06.94.94 0 0 1 .45.36 1 1 0 1 1-1.75.94 1 1 0 0 1-.08-.56zm7.08 9.46L13 13.342v-5.35h-1v5.34l-1.08-1.08-.71.71 1.94 1.93h.71l1.93-1.93-.71-.71zm-5.92-4.16h.71l1.93 1.93-.71.71-1.08-1.08v5.34h-1v-5.35l-1.08 1.09-.71-.71 1.94-1.93z"/></svg>;
}
}

export function DoSuggestIcon(): JSX.Element {
return <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/>
</svg>
}

export function DoNotSuggestIcon(): JSX.Element {
return <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94z"/>
<path d="M 15.888 0.838 L 0.853 15.884 C 0.853 15.884 0.145 15.178 0.145 15.178 L 15.18 0.132 Z"/>
</svg>
}

15 changes: 15 additions & 0 deletions infoview/util.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import * as React from 'react';
import { Event } from 'lean-client-js-core';

export function delayedThrottled(ms: number, cb: () => void): () => void {
const waiting = React.useRef<boolean>(false);
const callbackRef = React.useRef<() => void>();
callbackRef.current = cb;
return () => {
if (!waiting.current) {
waiting.current = true;
setTimeout(() => {
waiting.current = false;
callbackRef.current();
}, ms);
}
};
}

/** Split a string by a regex, executing `f_no_match` on the pieces which don't match, `f_match` on the pieces which do,
and concatenating the results into an array. */
export function regexMap<T>(regex: RegExp, s: string, f_no_match: (snm : string) => T, f_match: (m : RegExpExecArray) => T ) : T[] {
Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@
"default": "lean",
"markdownDescription": "Path to the Lean executable to use. **DO NOT CHANGE** from the default `lean` unless you know what you're doing!"
},
"lean.suggestionURL": {
"type": "string",
"default": "",
"markdownDescription": "URL to contact for model suggestions"
},
"lean.suggestionAPIKey": {
"type": "string",
"default": "",
"markdownDescription": "API Key to use for suggestions"
},
"lean.leanpkgPath": {
"type": "string",
"default": "leanpkg",
Expand Down
54 changes: 52 additions & 2 deletions src/infoview.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { InfoResponse, Connection } from 'lean-client-js-node';
import { WidgetIdentifier, Request, GetWidgetRequest } from 'lean-client-js-core';
import { join } from 'path';
import { AxiosRequestConfig, AxiosResponse } from 'axios';
import axios from 'axios';
import {
commands, Disposable, DocumentSelector,
ExtensionContext, languages, Position, Range,
Expand All @@ -8,7 +11,10 @@ import {
Uri, ViewColumn, WebviewPanel, window, workspace, env,
} from 'vscode';
import { Server } from './server';
import { ToInfoviewMessage, FromInfoviewMessage, PinnedLocation, InsertTextMessage, ServerRequestMessage, RevealMessage, HoverPositionMessage, locationEq, Location, InfoViewTacticStateFilter } from './shared'
import {
ToInfoviewMessage, FromInfoviewMessage, PinnedLocation, InsertTextMessage, ServerRequestMessage, RevealMessage, HoverPositionMessage, locationEq, Location, InfoViewTacticStateFilter,
SuggestionsModelResult
} from './shared'
import { StaticServer } from './staticserver';
import { CodePointPosition } from './utils/utf16Position';

Expand Down Expand Up @@ -214,6 +220,9 @@ export class InfoProvider implements Disposable {
await this.sendConfig();
await this.postMessage({command: 'all_messages', messages: this.server.messages});
return;
case 'get_suggestions':
this.getSuggestions(message.reqId, message.goalState, message.prefix);
return;
}
}
private handleServerRequest(message: ServerRequestMessage) {
Expand Down Expand Up @@ -345,7 +354,7 @@ export class InfoProvider implements Disposable {

private getActiveCursorLocation(): Location | null {
if (!window.activeTextEditor || !languages.match(this.leanDocs, window.activeTextEditor.document)) {return null; }
let p = CodePointPosition.ofPosition(window.activeTextEditor.document, window.activeTextEditor.selection.active);
const p = CodePointPosition.ofPosition(window.activeTextEditor.document, window.activeTextEditor.selection.active);
return this.makeLocation(window.activeTextEditor.document.fileName, p);
}

Expand Down Expand Up @@ -388,4 +397,45 @@ export class InfoProvider implements Disposable {
</body>
</html>`
}

private getSuggestions(reqId: number, goalState: string, prefix: string): void {
const api_key : string = workspace.getConfiguration('lean').get('suggestionAPIKey');
const url : string = workspace.getConfiguration('lean').get('suggestionURL');
if (!url) {
void this.postMessage({
command: 'Suggestions',
results: {'error': 'lean.suggestionURL not set, please ask on Zulip for URL and API key.'},
reqId,
});
return
}
if (goalState === 'no goals') { return; }
const data = JSON.stringify({
tactic_state: goalState,
prefix
});
const config: AxiosRequestConfig = {
headers: {
'Content-Type': 'application/json;charset=utf-8',
'Content-Length': Buffer.byteLength(data),
'x-api-key': api_key,
}
};
axios.post(url, data, config).then(
(response: AxiosResponse<SuggestionsModelResult>) => {
void this.postMessage({
command: 'Suggestions',
results: response.data,
reqId
});
},
(reason) => {
void this.postMessage({
command: 'Suggestions_error',
error: `failed to post ${data} for the following reason: ${reason}`
});
}
);
}

}
Loading

0 comments on commit d62eece

Please sign in to comment.