From f8277c622e0d927d01901c49fb263d363de845b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Wed, 15 Jan 2025 14:24:41 +0100 Subject: [PATCH] Adds an Ollama example --- .cursorrules | 13 + .pnp.cjs | 173 +++++++++++- docs/XTermRun.tsx | 6 + docs/examples/ollama/page.mdx | 8 + docs/examples/simple-form/page.mdx | 2 +- docs/navigation.ts | 3 + examples/index.ts | 5 +- examples/ollama.tsx | 259 ++++++++++++++++++ .../{small-form.tsx => small-form-react.tsx} | 0 package.json | 7 +- sources/dom/TermElement.ts | 15 +- sources/dom/TermScreen.ts | 12 +- sources/elements/TermInput.ts | 1 + sources/elements/TermMd.ts | 26 ++ sources/elements/TermText.ts | 7 + sources/react/ElementMap.ts | 3 + sources/react/Reconciler.ts | 2 +- sources/style/StyleValues.ts | 11 + sources/style/styleProperties.ts | 8 + yarn.lock | 114 +++++++- 20 files changed, 657 insertions(+), 18 deletions(-) create mode 100644 .cursorrules create mode 100644 docs/examples/ollama/page.mdx create mode 100644 examples/ollama.tsx rename examples/{small-form.tsx => small-form-react.tsx} (100%) create mode 100644 sources/elements/TermMd.ts diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..2ee0a0e --- /dev/null +++ b/.cursorrules @@ -0,0 +1,13 @@ +This repository contains a TypeScript library for building terminal applications, either using React or directly through its TypeScript APIs. + +Not all HTML elements are supported. All elements are prefixed with `term:` to avoid conflicts with the HTML namespace. + +Inline elements are not supported - only block elements are supported (no `term:span`). + +Layout can be constructed using flexbox properties. + +Style properties are inlined - ie `` rather than ``. + +Referencing the props of a React component can be done using, for example, `JSX.IntrinsicElements['term:div']`. + +The documentation is available in the `docs` folder. diff --git a/.pnp.cjs b/.pnp.cjs index 036d085..bb52ad0 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -27,6 +27,7 @@ const RAW_RUNTIME_STATE = "packageLocation": "./",\ "packageDependencies": [\ ["@arcanis/slice-ansi", "npm:2.0.1"],\ + ["@reduxjs/toolkit", "virtual:94d85169d29260b76ffb0219eec7ea8a47862ee24c417cc67801a50351aabc75e3f1a8e4fb27a447fea9891b861552e01dce89d0e4e10725cf4ae6f3f194f720#npm:2.5.0"],\ ["@types/node", "npm:20.11.24"],\ ["@types/react", "npm:18.2.61"],\ ["@types/react-reconciler", "npm:0.28.2"],\ @@ -39,11 +40,13 @@ const RAW_RUNTIME_STATE = ["lorem-ipsum", "npm:2.0.8"],\ ["mono-layout", "npm:0.14.3"],\ ["node-pty", "npm:1.1.0-beta27"],\ + ["ollama", "npm:0.5.12"],\ ["react", "npm:18.2.0"],\ ["react-reconciler", "virtual:94d85169d29260b76ffb0219eec7ea8a47862ee24c417cc67801a50351aabc75e3f1a8e4fb27a447fea9891b861552e01dce89d0e4e10725cf4ae6f3f194f720#npm:0.29.0"],\ + ["react-redux", "virtual:94d85169d29260b76ffb0219eec7ea8a47862ee24c417cc67801a50351aabc75e3f1a8e4fb27a447fea9891b861552e01dce89d0e4e10725cf4ae6f3f194f720#npm:9.2.0"],\ ["react-refresh", "npm:0.14.0"],\ ["tailwindcss", "npm:3.4.1"],\ - ["term-strings", "npm:0.15.2"],\ + ["term-strings", "npm:0.16.0"],\ ["tsx", "npm:4.7.1"],\ ["typescript", "patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7"],\ ["vitest", "virtual:94d85169d29260b76ffb0219eec7ea8a47862ee24c417cc67801a50351aabc75e3f1a8e4fb27a447fea9891b861552e01dce89d0e4e10725cf4ae6f3f194f720#npm:1.3.1"],\ @@ -629,6 +632,36 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@reduxjs/toolkit", [\ + ["npm:2.5.0", {\ + "packageLocation": "../.yarn/berry/cache/@reduxjs-toolkit-npm-2.5.0-9bdc99574d-10c0.zip/node_modules/@reduxjs/toolkit/",\ + "packageDependencies": [\ + ["@reduxjs/toolkit", "npm:2.5.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:94d85169d29260b76ffb0219eec7ea8a47862ee24c417cc67801a50351aabc75e3f1a8e4fb27a447fea9891b861552e01dce89d0e4e10725cf4ae6f3f194f720#npm:2.5.0", {\ + "packageLocation": "./.yarn/__virtual__/@reduxjs-toolkit-virtual-513ac655d0/2/.yarn/berry/cache/@reduxjs-toolkit-npm-2.5.0-9bdc99574d-10c0.zip/node_modules/@reduxjs/toolkit/",\ + "packageDependencies": [\ + ["@reduxjs/toolkit", "virtual:94d85169d29260b76ffb0219eec7ea8a47862ee24c417cc67801a50351aabc75e3f1a8e4fb27a447fea9891b861552e01dce89d0e4e10725cf4ae6f3f194f720#npm:2.5.0"],\ + ["@types/react", "npm:18.2.61"],\ + ["@types/react-redux", null],\ + ["immer", "npm:10.1.1"],\ + ["react", "npm:18.2.0"],\ + ["react-redux", "virtual:94d85169d29260b76ffb0219eec7ea8a47862ee24c417cc67801a50351aabc75e3f1a8e4fb27a447fea9891b861552e01dce89d0e4e10725cf4ae6f3f194f720#npm:9.2.0"],\ + ["redux", "npm:5.0.1"],\ + ["redux-thunk", "virtual:513ac655d0d8ec3e6cdd86bede468a55df9180e3281c7dcb89c67497e123515cecafc5040ad953896533739f296e7c1f234ff88589565f90ee62e0c30788a2ac#npm:3.1.0"],\ + ["reselect", "npm:5.1.1"]\ + ],\ + "packagePeers": [\ + "@types/react-redux",\ + "@types/react",\ + "react-redux",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@rollup/rollup-android-arm-eabi", [\ ["npm:4.12.0", {\ "packageLocation": "./.yarn/unplugged/@rollup-rollup-android-arm-eabi-npm-4.12.0-e216111d4f/node_modules/@rollup/rollup-android-arm-eabi/",\ @@ -870,6 +903,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@types/use-sync-external-store", [\ + ["npm:0.0.6", {\ + "packageLocation": "../.yarn/berry/cache/@types-use-sync-external-store-npm-0.0.6-9e5c635381-10c0.zip/node_modules/@types/use-sync-external-store/",\ + "packageDependencies": [\ + ["@types/use-sync-external-store", "npm:0.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@types/zen-observable", [\ ["npm:0.8.3", {\ "packageLocation": "../.yarn/berry/cache/@types-zen-observable-npm-0.8.3-b3fac445d1-10c0.zip/node_modules/@types/zen-observable/",\ @@ -1751,6 +1793,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["immer", [\ + ["npm:10.1.1", {\ + "packageLocation": "../.yarn/berry/cache/immer-npm-10.1.1-973ae10d09-10c0.zip/node_modules/immer/",\ + "packageDependencies": [\ + ["immer", "npm:10.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["imurmurhash", [\ ["npm:0.1.4", {\ "packageLocation": "../.yarn/berry/cache/imurmurhash-npm-0.1.4-610c5068a0-10c0.zip/node_modules/imurmurhash/",\ @@ -2331,6 +2382,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["ollama", [\ + ["npm:0.5.12", {\ + "packageLocation": "../.yarn/berry/cache/ollama-npm-0.5.12-87b8362872-10c0.zip/node_modules/ollama/",\ + "packageDependencies": [\ + ["ollama", "npm:0.5.12"],\ + ["whatwg-fetch", "npm:3.6.20"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["onetime", [\ ["npm:6.0.0", {\ "packageLocation": "../.yarn/berry/cache/onetime-npm-6.0.0-4f3684e29a-10c0.zip/node_modules/onetime/",\ @@ -2678,6 +2739,34 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["react-redux", [\ + ["npm:9.2.0", {\ + "packageLocation": "../.yarn/berry/cache/react-redux-npm-9.2.0-d87bb27c82-10c0.zip/node_modules/react-redux/",\ + "packageDependencies": [\ + ["react-redux", "npm:9.2.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:94d85169d29260b76ffb0219eec7ea8a47862ee24c417cc67801a50351aabc75e3f1a8e4fb27a447fea9891b861552e01dce89d0e4e10725cf4ae6f3f194f720#npm:9.2.0", {\ + "packageLocation": "./.yarn/__virtual__/react-redux-virtual-02d52350aa/2/.yarn/berry/cache/react-redux-npm-9.2.0-d87bb27c82-10c0.zip/node_modules/react-redux/",\ + "packageDependencies": [\ + ["react-redux", "virtual:94d85169d29260b76ffb0219eec7ea8a47862ee24c417cc67801a50351aabc75e3f1a8e4fb27a447fea9891b861552e01dce89d0e4e10725cf4ae6f3f194f720#npm:9.2.0"],\ + ["@types/react", "npm:18.2.61"],\ + ["@types/redux", null],\ + ["@types/use-sync-external-store", "npm:0.0.6"],\ + ["react", "npm:18.2.0"],\ + ["redux", null],\ + ["use-sync-external-store", "virtual:02d52350aa1f766fd414968e10b8ef7379e27ac584528548d529aa2b10eb0a7defdfca7198dd0590c7e3d49907e81041c0e9e8ec4e98a024a329f7dd9f181b81#npm:1.4.0"]\ + ],\ + "packagePeers": [\ + "@types/react",\ + "@types/redux",\ + "react",\ + "redux"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["react-refresh", [\ ["npm:0.14.0", {\ "packageLocation": "../.yarn/berry/cache/react-refresh-npm-0.14.0-78ef5eeb73-10c0.zip/node_modules/react-refresh/",\ @@ -2707,6 +2796,46 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["redux", [\ + ["npm:5.0.1", {\ + "packageLocation": "../.yarn/berry/cache/redux-npm-5.0.1-f8e6b1cb23-10c0.zip/node_modules/redux/",\ + "packageDependencies": [\ + ["redux", "npm:5.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["redux-thunk", [\ + ["npm:3.1.0", {\ + "packageLocation": "../.yarn/berry/cache/redux-thunk-npm-3.1.0-6a8fdd3211-10c0.zip/node_modules/redux-thunk/",\ + "packageDependencies": [\ + ["redux-thunk", "npm:3.1.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:513ac655d0d8ec3e6cdd86bede468a55df9180e3281c7dcb89c67497e123515cecafc5040ad953896533739f296e7c1f234ff88589565f90ee62e0c30788a2ac#npm:3.1.0", {\ + "packageLocation": "./.yarn/__virtual__/redux-thunk-virtual-beae52d0a4/2/.yarn/berry/cache/redux-thunk-npm-3.1.0-6a8fdd3211-10c0.zip/node_modules/redux-thunk/",\ + "packageDependencies": [\ + ["redux-thunk", "virtual:513ac655d0d8ec3e6cdd86bede468a55df9180e3281c7dcb89c67497e123515cecafc5040ad953896533739f296e7c1f234ff88589565f90ee62e0c30788a2ac#npm:3.1.0"],\ + ["@types/redux", null],\ + ["redux", "npm:5.0.1"]\ + ],\ + "packagePeers": [\ + "@types/redux",\ + "redux"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["reselect", [\ + ["npm:5.1.1", {\ + "packageLocation": "../.yarn/berry/cache/reselect-npm-5.1.1-667568f51c-10c0.zip/node_modules/reselect/",\ + "packageDependencies": [\ + ["reselect", "npm:5.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["resolve", [\ ["patch:resolve@npm%3A1.22.8#optional!builtin::version=1.22.8&hash=c3c19d", {\ "packageLocation": "../.yarn/berry/cache/resolve-patch-4254c24959-10c0.zip/node_modules/resolve/",\ @@ -3055,10 +3184,10 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["term-strings", [\ - ["npm:0.15.2", {\ - "packageLocation": "../.yarn/berry/cache/term-strings-npm-0.15.2-7fce45665e-10c0.zip/node_modules/term-strings/",\ + ["npm:0.16.0", {\ + "packageLocation": "../.yarn/berry/cache/term-strings-npm-0.16.0-7bf2ec1e2f-10c0.zip/node_modules/term-strings/",\ "packageDependencies": [\ - ["term-strings", "npm:0.15.2"],\ + ["term-strings", "npm:0.16.0"],\ ["@types/zen-observable", "npm:0.8.3"],\ ["color-diff", "npm:1.2.0"],\ ["zen-observable", "npm:0.8.15"]\ @@ -3072,6 +3201,7 @@ const RAW_RUNTIME_STATE = "packageDependencies": [\ ["terminosaurus", "workspace:."],\ ["@arcanis/slice-ansi", "npm:2.0.1"],\ + ["@reduxjs/toolkit", "virtual:94d85169d29260b76ffb0219eec7ea8a47862ee24c417cc67801a50351aabc75e3f1a8e4fb27a447fea9891b861552e01dce89d0e4e10725cf4ae6f3f194f720#npm:2.5.0"],\ ["@types/node", "npm:20.11.24"],\ ["@types/react", "npm:18.2.61"],\ ["@types/react-reconciler", "npm:0.28.2"],\ @@ -3084,11 +3214,13 @@ const RAW_RUNTIME_STATE = ["lorem-ipsum", "npm:2.0.8"],\ ["mono-layout", "npm:0.14.3"],\ ["node-pty", "npm:1.1.0-beta27"],\ + ["ollama", "npm:0.5.12"],\ ["react", "npm:18.2.0"],\ ["react-reconciler", "virtual:94d85169d29260b76ffb0219eec7ea8a47862ee24c417cc67801a50351aabc75e3f1a8e4fb27a447fea9891b861552e01dce89d0e4e10725cf4ae6f3f194f720#npm:0.29.0"],\ + ["react-redux", "virtual:94d85169d29260b76ffb0219eec7ea8a47862ee24c417cc67801a50351aabc75e3f1a8e4fb27a447fea9891b861552e01dce89d0e4e10725cf4ae6f3f194f720#npm:9.2.0"],\ ["react-refresh", "npm:0.14.0"],\ ["tailwindcss", "npm:3.4.1"],\ - ["term-strings", "npm:0.15.2"],\ + ["term-strings", "npm:0.16.0"],\ ["tsx", "npm:4.7.1"],\ ["typescript", "patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7"],\ ["vitest", "virtual:94d85169d29260b76ffb0219eec7ea8a47862ee24c417cc67801a50351aabc75e3f1a8e4fb27a447fea9891b861552e01dce89d0e4e10725cf4ae6f3f194f720#npm:1.3.1"],\ @@ -3252,6 +3384,28 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["use-sync-external-store", [\ + ["npm:1.4.0", {\ + "packageLocation": "../.yarn/berry/cache/use-sync-external-store-npm-1.4.0-176448bea1-10c0.zip/node_modules/use-sync-external-store/",\ + "packageDependencies": [\ + ["use-sync-external-store", "npm:1.4.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:02d52350aa1f766fd414968e10b8ef7379e27ac584528548d529aa2b10eb0a7defdfca7198dd0590c7e3d49907e81041c0e9e8ec4e98a024a329f7dd9f181b81#npm:1.4.0", {\ + "packageLocation": "./.yarn/__virtual__/use-sync-external-store-virtual-b2a522ebb1/2/.yarn/berry/cache/use-sync-external-store-npm-1.4.0-176448bea1-10c0.zip/node_modules/use-sync-external-store/",\ + "packageDependencies": [\ + ["use-sync-external-store", "virtual:02d52350aa1f766fd414968e10b8ef7379e27ac584528548d529aa2b10eb0a7defdfca7198dd0590c7e3d49907e81041c0e9e8ec4e98a024a329f7dd9f181b81#npm:1.4.0"],\ + ["@types/react", "npm:18.2.61"],\ + ["react", "npm:18.2.0"]\ + ],\ + "packagePeers": [\ + "@types/react",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["util-deprecate", [\ ["npm:1.0.2", {\ "packageLocation": "../.yarn/berry/cache/util-deprecate-npm-1.0.2-e3fe1a219c-10c0.zip/node_modules/util-deprecate/",\ @@ -3440,6 +3594,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["whatwg-fetch", [\ + ["npm:3.6.20", {\ + "packageLocation": "../.yarn/berry/cache/whatwg-fetch-npm-3.6.20-a6f79b98c4-10c0.zip/node_modules/whatwg-fetch/",\ + "packageDependencies": [\ + ["whatwg-fetch", "npm:3.6.20"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["which", [\ ["npm:2.0.2", {\ "packageLocation": "../.yarn/berry/cache/which-npm-2.0.2-320ddf72f7-10c0.zip/node_modules/which/",\ diff --git a/docs/XTermRun.tsx b/docs/XTermRun.tsx index 9e3765d..5469de6 100644 --- a/docs/XTermRun.tsx +++ b/docs/XTermRun.tsx @@ -4,6 +4,9 @@ import * as terminosaurusReact from 'terminosaurus/react'; import {XTerm, XTermScreenIn, XTermScreenOut} from 'terminosaurus/xterm'; import * as terminosaurus from 'terminosaurus'; import React, {useEffect, useRef} from 'react'; +import ollama from 'ollama/browser'; +import * as reactRedux from 'react-redux'; +import * as reduxToolkit from '@reduxjs/toolkit'; import {PassThrough} from 'stream'; import grammar from '#data/languages/TypeScript.tmLanguage.json'; @@ -61,6 +64,9 @@ export function XTermRun({className = ``, code, rows}: {className?: string, code [`react`]: React, [`terminosaurus`]: patchedTerminosaurus, [`terminosaurus/react`]: patchedTerminosaurusReact, + [`ollama`]: {default: ollama}, + [`react-redux`]: reactRedux, + [`@reduxjs/toolkit`]: reduxToolkit, [`#data/languages/TypeScript.tmLanguage.json`]: {default: grammar}, [`#data/themes/WinterIsComing.json`]: {default: theme}, }; diff --git a/docs/examples/ollama/page.mdx b/docs/examples/ollama/page.mdx new file mode 100644 index 0000000..86c12a1 --- /dev/null +++ b/docs/examples/ollama/page.mdx @@ -0,0 +1,8 @@ +--- +layout: fixed +nextjs: + metadata: + title: Ollama example +--- + + diff --git a/docs/examples/simple-form/page.mdx b/docs/examples/simple-form/page.mdx index 2fe9799..2507cbe 100644 --- a/docs/examples/simple-form/page.mdx +++ b/docs/examples/simple-form/page.mdx @@ -5,4 +5,4 @@ nextjs: title: Simple form example --- - + diff --git a/docs/navigation.ts b/docs/navigation.ts index 828c955..e890ab1 100644 --- a/docs/navigation.ts +++ b/docs/navigation.ts @@ -27,6 +27,9 @@ export const navigation = [{ }, { title: `Simple form`, href: `/docs/examples/simple-form`, + }, { + title: `Ollama chat`, + href: `/docs/examples/ollama`, }], }, { title: `Entry points`, diff --git a/examples/index.ts b/examples/index.ts index 5e887a5..3f96563 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -11,17 +11,20 @@ import { render, run, } from 'terminosaurus/react'; +import { RunOptions } from '../sources/dom/TermScreen'; runExit(class Main extends Command { + public debugPaintRects = Option.Boolean(`--debug-paint-rects`, false); public logOutput = Option.Boolean(`--log-output`, false); public console = Option.String(`--console`); public examplePath = Option.String(); async execute() { - let streams: ScreenStreams = { + let streams: ScreenStreams & RunOptions = { stdin: this.context.stdin, stdout: this.context.stdout, + debugPaintRects: this.debugPaintRects, }; if (this.logOutput) { diff --git a/examples/ollama.tsx b/examples/ollama.tsx new file mode 100644 index 0000000..74bde61 --- /dev/null +++ b/examples/ollama.tsx @@ -0,0 +1,259 @@ +import {useState} from 'react'; +import {Provider, useDispatch, useSelector} from 'react-redux'; + +import { createSlice, createAsyncThunk, PayloadAction, configureStore } from '@reduxjs/toolkit'; +import ollama from 'ollama'; + +interface Message { + role: 'user' | 'assistant'; + content: string; +} + +interface Discussion { + id: string; + title: string | null; + messages: Message[]; +} + +export interface OllamaState { + discussions: Discussion[]; + currentDiscussionId: string; + isProcessing: boolean; + error: string | null; +} + +const initialDiscussionId = crypto.randomUUID(); + +const initialState: OllamaState = { + discussions: [{id: initialDiscussionId, title: null, messages: []}], + currentDiscussionId: initialDiscussionId, + isProcessing: false, + error: null, +}; + +export const sendMessage = createAsyncThunk( + 'ollama/sendMessage', + async ({ content, discussionId }: { content: string, discussionId: string }, { getState }) => { + const state = getState() as { ollama: OllamaState }; + const discussion = state.ollama.discussions.find(d => d.id === discussionId)!; + + const response = await ollama.chat({ + model: 'llama3.1', + messages: [ + ...discussion.messages.map(msg => ({ + role: msg.role, + content: msg.content, + })), + { role: 'user' as const, content } + ], + stream: false + }); + + return response.message.content; + } +); + +export const generateTitle = createAsyncThunk( + 'ollama/generateTitle', + async ({ discussionId }: { discussionId: string }, { getState }) => { + const state = getState() as { ollama: OllamaState }; + const discussion = state.ollama.discussions.find(d => d.id === discussionId)!; + + const firstMessage = discussion.messages[0].content; + const response = discussion.messages[1].content; + + const titleResponse = await ollama.chat({ + model: 'llama3.1', + messages: [ + { role: 'system', content: 'You are a title generator. Respond with just the title, no quotes or explanation.' }, + { role: 'user', content: `Based on this conversation, generate a very short title (max 30 chars):\nUser: ${firstMessage}\nAssistant: ${response}` } + ], + stream: false + }); + + return { + title: titleResponse.message.content.slice(0, 30), + discussionId + }; + } +); + +const ollamaSlice = createSlice({ + name: 'ollama', + initialState, + reducers: { + createNewDiscussion: (state) => { + const newId = crypto.randomUUID(); + state.discussions.push({id: newId, title: null, messages: []}); + state.currentDiscussionId = newId; + state.error = null; + }, + setCurrentDiscussion: (state, action: PayloadAction) => { + state.currentDiscussionId = action.payload; + state.error = null; + }, + }, + extraReducers: (builder) => { + builder.addCase(sendMessage.pending, (state, action) => { + const userMessage: Message = {role: 'user', content: action.meta.arg.content}; + + const discussion = state.discussions.find(d => d.id === action.meta.arg.discussionId)!; + discussion.messages.push(userMessage); + + state.isProcessing = true; + state.error = null; + }); + + builder.addCase(sendMessage.fulfilled, (state, action) => { + const discussion = state.discussions.find(d => d.id === action.meta.arg.discussionId)!; + + const assistantMessage: Message = {role: 'assistant', content: action.payload}; + discussion.messages.push(assistantMessage); + + state.isProcessing = false; + }); + + builder.addCase(sendMessage.rejected, (state, action) => { + state.isProcessing = false; + state.error = action.error.message ?? 'Failed to connect to Ollama. Make sure it is running locally.'; + }); + + builder.addCase(generateTitle.fulfilled, (state, action) => { + const discussion = state.discussions.find(d => d.id === action.payload.discussionId)!; + discussion.title = action.payload.title; + }); + + builder.addCase(generateTitle.rejected, (state, action) => { + state.error = action.error.message ?? 'Failed to generate title.'; + }); + }, +}); + +const { + createNewDiscussion, + setCurrentDiscussion, +} = ollamaSlice.actions; + +const store = configureStore({ + reducer: { + ollama: ollamaSlice.reducer, + }, +}); + +type RootState = ReturnType; +type AppDispatch = typeof store.dispatch; + +function useAppSelector(selector: (state: RootState) => T) { + return useSelector(selector); +} + +function useAppDispatch() { + return useDispatch(); +} + +// Note: should move that to term-strings +const hyperlink = (text: string, url: string) => { + return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`; +}; + +function OllamaApp() { + const [input, setInput] = useState(''); + const dispatch = useAppDispatch(); + + const discussions = useAppSelector((state: RootState) => state.ollama.discussions); + const currentDiscussionId = useAppSelector((state: RootState) => state.ollama.currentDiscussionId); + const isProcessing = useAppSelector((state: RootState) => state.ollama.isProcessing); + const error = useAppSelector((state: RootState) => state.ollama.error); + + const currentDiscussion = discussions.find(d => d.id === currentDiscussionId)!; + + const handleSubmit = async () => { + if (!input.trim() || isProcessing) + return; + + const trimmedInput = input.trim(); + + if (trimmedInput === '/new') { + dispatch(createNewDiscussion()); + setInput(''); + return; + } + + setInput(''); + + await dispatch(sendMessage({ + content: trimmedInput, + discussionId: currentDiscussionId, + })).unwrap(); + + // If this is the first message exchange, generate a title + if (currentDiscussion.title === null) { + dispatch(generateTitle({ + discussionId: currentDiscussionId, + })); + } + }; + + return ( + + {/* Discussions list */} + + {discussions.map((discussion) => ( + dispatch(setCurrentDiscussion(discussion.id))}> + {discussion.title ?? `Untitled`} + + ))} + + + + + {currentDiscussion.messages.length === 0 && ( + + Welcome to the Terminosaurus Ollama Chat! + This is a terminal-based chat interface for {hyperlink(`Ollama`, `https://ollama.ai/`)}. To use this application: + 1. Make sure Ollama is installed and running locally + 2. Type your message in the input box below and press Enter + 3. Use /new to start a new conversation + Note: This application requires Ollama to be running locally with the llama3.1 model installed. And don't forget to exit Ollama when you're done, as other webpages could query it the same way we do! + + )} + + {currentDiscussion.messages.map((message, index) => ( + + + {message.role === 'user' ? 'You' : 'Ollama'}: + + + {message.content} + + + ))} + + {error && ( + + {error} + + )} + + {isProcessing && ( + + Ollama is thinking... + + )} + + + + setInput(e.target.text)}/> + + + + ); +} + +export function App() { + return ( + + + + ); +} diff --git a/examples/small-form.tsx b/examples/small-form-react.tsx similarity index 100% rename from examples/small-form.tsx rename to examples/small-form-react.tsx diff --git a/package.json b/package.json index 7a6341f..21d456e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "terminosaurus", - "version": "3.0.0-rc.2", + "version": "3.0.0-rc.3", "packageManager": "yarn@4.1.0+sha224.bc24d7f5afc738464f3d4e95f4e6e7829a35cee54a0fd527ea5baa83", "workspaces": [ "website" @@ -31,13 +31,14 @@ "node-pty": "1.1.0-beta27", "react-reconciler": "^0.29.0", "react-refresh": "^0.14.0", - "term-strings": "^0.15.2", + "term-strings": "^0.16.0", "vscode-oniguruma": "^1.7.0", "vscode-textmate": "^8.0.0", "yogini": "^0.4.2", "zen-observable": "^0.9.0" }, "devDependencies": { + "@reduxjs/toolkit": "^2.5.0", "@types/node": "^20.11.24", "@types/react": "^18.0.26", "@types/react-reconciler": "^0.28.0", @@ -47,7 +48,9 @@ "clipanion": "^3.2.1", "esbuild": "^0.20.1", "lorem-ipsum": "^2.0.8", + "ollama": "^0.5.12", "react": "^18.2.0", + "react-redux": "^9.2.0", "tailwindcss": "^3.4.1", "tsx": "^4.7.1", "typescript": "^5.3.3", diff --git a/sources/dom/TermElement.ts b/sources/dom/TermElement.ts index 2791ed9..3434948 100644 --- a/sources/dom/TermElement.ts +++ b/sources/dom/TermElement.ts @@ -699,7 +699,7 @@ export class TermElement extends TermNode { const prevScrollX = this.scrollRect.x; const prevScrollY = this.scrollRect.y; - if (!this.styleManager.computed.overflow.doesHideOverflow) { + if (this.styleManager.computed.overflow.doesAllowScroll) { this.scrollRect.x = Math.min(this.scrollRect.x, this.scrollRect.w - this.elementRect.w); this.scrollRect.y = Math.min(this.scrollRect.y, this.scrollRect.h - this.elementRect.h); } else { @@ -764,6 +764,16 @@ export class TermElement extends TermNode { if (this.styleManager.computed.overflow.doesHideOverflow || !relativeClipRect) { relativeClipRect = {...this.elementClipRect!}; + if (this.styleManager.computed.borderLeftCharacter) { + relativeClipRect.x += 1; + relativeClipRect.w -= 1; + } + + if (this.styleManager.computed.borderTopCharacter) { + relativeClipRect.y += 1; + relativeClipRect.h -= 1; + } + if (this.styleManager.computed.borderRightCharacter) relativeClipRect.w -= 1; @@ -975,6 +985,9 @@ export class TermElement extends TermNode { else if (this.styleManager.computed.fontWeight === StyleValues.FontWeight.Bold) prefix += style.emboldened.in; + if (this.styleManager.computed.fontStyle === StyleValues.FontStyle.Italic) + prefix += style.italic.in; + if (this.styleManager.computed.textDecoration === StyleValues.TextDecoration.Underline) prefix += style.underlined.in; diff --git a/sources/dom/TermScreen.ts b/sources/dom/TermScreen.ts index 3461600..134a2d1 100644 --- a/sources/dom/TermScreen.ts +++ b/sources/dom/TermScreen.ts @@ -1,5 +1,5 @@ import {Info, Key, Mouse, parseTerminalInputs, Production} from 'term-strings/parse'; -import {cursor, feature, request, screen, style} from 'term-strings'; +import {ansiPattern, cursor, feature, request, screen, style} from 'term-strings'; import Observable from 'zen-observable'; import {TermElement} from '#sources/dom/TermElement'; @@ -38,6 +38,7 @@ export type ScreenStreams = { }; export type RunOptions = Partial & { + debugPaintRects?: boolean; trackOutputSize?: boolean; throttleMouseMoveEvents?: number; }; @@ -90,6 +91,9 @@ export class TermScreen { } async run(opts: RunOptions, fn: () => void | undefined) { + if (opts.debugPaintRects) + this.rootNode.debugPaintRects = true; + this.attachScreen(opts); return new Promise(resolve => { @@ -367,6 +371,7 @@ export class TermScreen { while (dirtyRects.length > 0) { const dirtyRect = dirtyRects.shift()!; + console.log(dirtyRect); for (const element of this.rootNode.renderList) { if (!element.elementClipRect) @@ -385,8 +390,10 @@ export class TermScreen { let line = String(element.renderElement(relativeX, relativeY, intersection.w)); - if (this.rootNode.debugPaintRects) + if (this.rootNode.debugPaintRects) { + line = line.replace(new RegExp(ansiPattern(), `g`), ``); line = style.color.back(debugColor) + line + style.clear; + } buffer += cursor.moveTo({x: intersection.x, y: intersection.y + y}); buffer += line; @@ -421,6 +428,7 @@ export class TermScreen { }; handleExit = () => { + this.detachScreen(); }; diff --git a/sources/elements/TermInput.ts b/sources/elements/TermInput.ts index 6897884..84ea681 100644 --- a/sources/elements/TermInput.ts +++ b/sources/elements/TermInput.ts @@ -7,6 +7,7 @@ import {makeRuleset} from '#sources/style/tools/makeRuleset'; const ruleset = makeRuleset({ [`*`]: { whiteSpace: StyleValues.WhiteSpace.Pre, + overflow: StyleValues.Overflow.Scroll, focusEvents: true, }, [`:decorated`]: { diff --git a/sources/elements/TermMd.ts b/sources/elements/TermMd.ts new file mode 100644 index 0000000..151a78c --- /dev/null +++ b/sources/elements/TermMd.ts @@ -0,0 +1,26 @@ +import {TermElement} from '#sources/dom/TermElement'; + +export type TermCanvasRenderFn = ( + element: TermCanvas, + x: number, + y: number, + l: number, +) => string | null; + +export class TermCanvas extends TermElement { + render: TermCanvasRenderFn | null = null; + + resetRender() { + this.setRender(null); + } + + setRender(fn: TermCanvasRenderFn | null) { + this.render = fn; + + this.queueDirtyRect(); + } + + renderContent(x: number, y: number, l: number) { + return this.render?.(this, x, y, l) ?? this.renderBackground(l); + } +} diff --git a/sources/elements/TermText.ts b/sources/elements/TermText.ts index c92de20..c878f8f 100644 --- a/sources/elements/TermText.ts +++ b/sources/elements/TermText.ts @@ -319,7 +319,14 @@ export class TermText extends TermElement { } setText(content: string) { + const updateCaret = this.caretIndex !== null && content.length < this.caretIndex; this.textBuffer.setValue(content); + + if (updateCaret) { + this.caretIndex = content.length; + this.caret = this.textLayout.getPositionForCharacterIndex(this.caretIndex); + this.caretMaxColumn = this.caret.x; + } } getPreferredSize(min: Point, max: Point) { diff --git a/sources/react/ElementMap.ts b/sources/react/ElementMap.ts index 7a88114..1584e7f 100644 --- a/sources/react/ElementMap.ts +++ b/sources/react/ElementMap.ts @@ -10,6 +10,7 @@ import {EventOf, EventSlot} from '#sources/misc/EventSource'; import {AllPropertiesInputs} from '#sources/style/styleProperties'; import { TermPty } from '../elements/TermPty'; import { TermButton, TermText } from '..'; +import { TermForm } from '../elements/TermForm'; type HasChildren = {children?: React.ReactNode}; @@ -40,6 +41,7 @@ declare global { 'term:canvas': AllReactPropsFor & AllReactEventsFor; 'term:div': AllReactPropsFor & AllReactEventsFor & HasChildren; 'term:editor': AllReactPropsFor & AllReactEventsFor; + 'term:form': AllReactPropsFor & AllReactEventsFor & HasChildren; 'term:input': AllReactPropsFor & AllReactEventsFor; 'term:text': AllReactPropsFor & AllReactEventsFor & HasChildren; 'term:pty': AllReactPropsFor & AllReactEventsFor; @@ -52,6 +54,7 @@ export const ElementMap = new Map([ [`term:canvas`, TermCanvas], [`term:div`, TermElement], [`term:editor`, TermEditor], + [`term:form`, TermForm], [`term:input`, TermInput], [`term:text`, TermText], [`term:pty`, TermPty], diff --git a/sources/react/Reconciler.ts b/sources/react/Reconciler.ts index 67f800d..b3a48f6 100644 --- a/sources/react/Reconciler.ts +++ b/sources/react/Reconciler.ts @@ -85,7 +85,7 @@ function triageProps(hostContext: HostContext, props: Record, oldPr if (k === `children`) { triagedProps.text = typeof v === `string` ? v : null; } else if (isPropertyName(k)) { - triagedProps.styles.set(k, parsePropertyValue(k, v)); + triagedProps.styles.set(k, v !== undefined ? parsePropertyValue(k, v) : undefined); } else if (k.match(eventRegExp) && v) { triagedProps.addedEvents.set(k, wrapWithBatch(v, hostContext)); } else { diff --git a/sources/style/StyleValues.ts b/sources/style/StyleValues.ts index dfcee7c..dccb386 100644 --- a/sources/style/StyleValues.ts +++ b/sources/style/StyleValues.ts @@ -93,6 +93,11 @@ export const StyleValues = { `BreakWord`, ]), + FontStyle: enumToStyle([ + `Normal`, + `Italic`, + ]), + FontWeight: enumToStyle([ `Light`, `Normal`, @@ -139,9 +144,15 @@ export const StyleValues = { Overflow: { Hidden: { doesHideOverflow: true, + doesAllowScroll: false, }, Visible: { doesHideOverflow: false, + doesAllowScroll: false, + }, + Scroll: { + doesHideOverflow: true, + doesAllowScroll: true, }, }, diff --git a/sources/style/styleProperties.ts b/sources/style/styleProperties.ts index 6ca0497..2d8ef30 100644 --- a/sources/style/styleProperties.ts +++ b/sources/style/styleProperties.ts @@ -107,6 +107,7 @@ export const styleParsers = { paddingTop: tuple([length.rel, length, inherit]), paddingBottom: tuple([length.rel, length, inherit]), + fontStyle: tuple([pick(StyleValues.FontStyle), inherit]), fontWeight: tuple([pick(StyleValues.FontWeight), inherit]), textAlign: tuple([pick(StyleValues.TextAlign), inherit]), textDecoration: tuple([pick(StyleValues.TextDecoration), null, inherit]), @@ -351,6 +352,13 @@ export const physicalProperties = { initial: StyleValues.Length.Zero, }, + fontStyle: { + parsers: styleParsers.fontStyle, + triggers: [dirtyRendering], + initial: StyleValues.Inherit, + default: StyleValues.FontStyle.Normal, + }, + fontWeight: { parsers: styleParsers.fontWeight, triggers: [dirtyRendering], diff --git a/yarn.lock b/yarn.lock index d9f5229..f310044 100644 --- a/yarn.lock +++ b/yarn.lock @@ -496,6 +496,26 @@ __metadata: languageName: node linkType: hard +"@reduxjs/toolkit@npm:^2.5.0": + version: 2.5.0 + resolution: "@reduxjs/toolkit@npm:2.5.0" + dependencies: + immer: "npm:^10.0.3" + redux: "npm:^5.0.1" + redux-thunk: "npm:^3.1.0" + reselect: "npm:^5.1.0" + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + checksum: 10c0/81748a5a6d2f52a14769b6ed25aea1e77cda81b1db6599c7c3a1d1605696c65c4469f55146c2b2a7a2f8ebafa5ecd4996aa8deecb37aebb5307217ec2fe384ac + languageName: node + linkType: hard + "@rollup/rollup-android-arm-eabi@npm:4.12.0": version: 4.12.0 resolution: "@rollup/rollup-android-arm-eabi@npm:4.12.0" @@ -695,6 +715,13 @@ __metadata: languageName: node linkType: hard +"@types/use-sync-external-store@npm:^0.0.6": + version: 0.0.6 + resolution: "@types/use-sync-external-store@npm:0.0.6" + checksum: 10c0/77c045a98f57488201f678b181cccd042279aff3da34540ad242f893acc52b358bd0a8207a321b8ac09adbcef36e3236944390e2df4fcedb556ce7bb2a88f2a8 + languageName: node + linkType: hard + "@types/zen-observable@npm:^0.8.3": version: 0.8.3 resolution: "@types/zen-observable@npm:0.8.3" @@ -1559,6 +1586,13 @@ __metadata: languageName: node linkType: hard +"immer@npm:^10.0.3": + version: 10.1.1 + resolution: "immer@npm:10.1.1" + checksum: 10c0/b749e10d137ccae91788f41bd57e9387f32ea6d6ea8fd7eb47b23fd7766681575efc7f86ceef7fe24c3bc9d61e38ff5d2f49c2663b2b0c056e280a4510923653 + languageName: node + linkType: hard + "imurmurhash@npm:^0.1.4": version: 0.1.4 resolution: "imurmurhash@npm:0.1.4" @@ -2081,6 +2115,15 @@ __metadata: languageName: node linkType: hard +"ollama@npm:^0.5.12": + version: 0.5.12 + resolution: "ollama@npm:0.5.12" + dependencies: + whatwg-fetch: "npm:^3.6.20" + checksum: 10c0/09944a14b6bd3a92f219ff635b33b14fb4e4095f04208ed9a25d6aceacd63ee9a95b13e2950d162842954c8cd83ec51dc782b4674460f83fb042e045727337b3 + languageName: node + linkType: hard + "onetime@npm:^6.0.0": version: 6.0.0 resolution: "onetime@npm:6.0.0" @@ -2327,6 +2370,25 @@ __metadata: languageName: node linkType: hard +"react-redux@npm:^9.2.0": + version: 9.2.0 + resolution: "react-redux@npm:9.2.0" + dependencies: + "@types/use-sync-external-store": "npm:^0.0.6" + use-sync-external-store: "npm:^1.4.0" + peerDependencies: + "@types/react": ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + redux: + optional: true + checksum: 10c0/00d485f9d9219ca1507b4d30dde5f6ff8fb68ba642458f742e0ec83af052f89e65cd668249b99299e1053cc6ad3d2d8ac6cb89e2f70d2ac5585ae0d7fa0ef259 + languageName: node + linkType: hard + "react-refresh@npm:^0.14.0": version: 0.14.0 resolution: "react-refresh@npm:0.14.0" @@ -2361,6 +2423,29 @@ __metadata: languageName: node linkType: hard +"redux-thunk@npm:^3.1.0": + version: 3.1.0 + resolution: "redux-thunk@npm:3.1.0" + peerDependencies: + redux: ^5.0.0 + checksum: 10c0/21557f6a30e1b2e3e470933247e51749be7f1d5a9620069a3125778675ce4d178d84bdee3e2a0903427a5c429e3aeec6d4df57897faf93eb83455bc1ef7b66fd + languageName: node + linkType: hard + +"redux@npm:^5.0.1": + version: 5.0.1 + resolution: "redux@npm:5.0.1" + checksum: 10c0/b10c28357194f38e7d53b760ed5e64faa317cc63de1fb95bc5d9e127fab956392344368c357b8e7a9bedb0c35b111e7efa522210cfdc3b3c75e5074718e9069c + languageName: node + linkType: hard + +"reselect@npm:^5.1.0": + version: 5.1.1 + resolution: "reselect@npm:5.1.1" + checksum: 10c0/219c30da122980f61853db3aebd173524a2accd4b3baec770e3d51941426c87648a125ca08d8c57daa6b8b086f2fdd2703cb035dd6231db98cdbe1176a71f489 + languageName: node + linkType: hard + "resolve-pkg-maps@npm:^1.0.0": version: 1.0.0 resolution: "resolve-pkg-maps@npm:1.0.0" @@ -2721,9 +2806,9 @@ __metadata: languageName: node linkType: hard -"term-strings@npm:^0.15.2": - version: 0.15.2 - resolution: "term-strings@npm:0.15.2" +"term-strings@npm:^0.16.0": + version: 0.16.0 + resolution: "term-strings@npm:0.16.0" dependencies: "@types/zen-observable": "npm:^0.8.3" color-diff: "npm:^1.2.0" @@ -2731,7 +2816,7 @@ __metadata: bin: term-strings: ./build/bin/term-strings.js term-strings-seqdbg: ./build/bin/term-strings-seqdbg.js - checksum: 10c0/3375d3807aee55ed68cac80f95be60dda0c0181655d6868cbfaf855e753fd0774f5da3211fabde1a9e4ce6d55f5f0f5d875030d9c9d2e9b03e6c687de0f23c9d + checksum: 10c0/91e4fbe71e3d988a7011673924b94de7c8f3724d4ffbc8ec0a6d97456ddfb08f49097304251066960b1022b41fef9fa27c049c69bf01aa85781b3c85b0275d0d languageName: node linkType: hard @@ -2740,6 +2825,7 @@ __metadata: resolution: "terminosaurus@workspace:." dependencies: "@arcanis/slice-ansi": "npm:^2.0.1" + "@reduxjs/toolkit": "npm:^2.5.0" "@types/node": "npm:^20.11.24" "@types/react": "npm:^18.0.26" "@types/react-reconciler": "npm:^0.28.0" @@ -2752,11 +2838,13 @@ __metadata: lorem-ipsum: "npm:^2.0.8" mono-layout: "npm:^0.14.3" node-pty: "npm:1.1.0-beta27" + ollama: "npm:^0.5.12" react: "npm:^18.2.0" react-reconciler: "npm:^0.29.0" + react-redux: "npm:^9.2.0" react-refresh: "npm:^0.14.0" tailwindcss: "npm:^3.4.1" - term-strings: "npm:^0.15.2" + term-strings: "npm:^0.16.0" tsx: "npm:^4.7.1" typescript: "npm:^5.3.3" vitest: "npm:^1.3.1" @@ -2913,6 +3001,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.4.0": + version: 1.4.0 + resolution: "use-sync-external-store@npm:1.4.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/ec011a5055962c0f6b509d6e78c0b143f8cd069890ae370528753053c55e3b360d3648e76cfaa854faa7a59eb08d6c5fb1015e60ffde9046d32f5b2a295acea5 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.2": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -3039,6 +3136,13 @@ __metadata: languageName: node linkType: hard +"whatwg-fetch@npm:^3.6.20": + version: 3.6.20 + resolution: "whatwg-fetch@npm:3.6.20" + checksum: 10c0/fa972dd14091321d38f36a4d062298df58c2248393ef9e8b154493c347c62e2756e25be29c16277396046d6eaa4b11bd174f34e6403fff6aaca9fb30fa1ff46d + languageName: node + linkType: hard + "which@npm:^2.0.1": version: 2.0.2 resolution: "which@npm:2.0.2"