From b4e12ce8c5657a4ad1191ada5c9c611241b0400a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 08:11:20 +0000 Subject: [PATCH 1/4] chore(dependencies): add Vercel AI SDK and Anthropic provider Add `ai` and `@ai-sdk/anthropic` packages for AI-powered terminal commands with streaming support. Co-Authored-By: Claude Opus 4.6 https://claude.ai/code/session_01FXuX3ypbwddUC3rFvYTneT --- package.json | 2 + pnpm-lock.yaml | 105 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a56829ba..ef6bb9ea 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "prepare": "husky && tsx scripts/sync-skills.ts" }, "dependencies": { + "@ai-sdk/anthropic": "^3.0.58", "@giscus/react": "^3.1.0", "@octokit/graphql": "^9.0.3", "@radix-ui/react-avatar": "^1.1.11", @@ -28,6 +29,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@vercel/analytics": "^2.0.1", + "ai": "^6.0.116", "babel-plugin-react-compiler": "1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b48d676..bb00ca8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: dependencies: + '@ai-sdk/anthropic': + specifier: ^3.0.58 + version: 3.0.58(zod@4.3.6) '@giscus/react': specifier: ^3.1.0 version: 3.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -53,7 +56,10 @@ importers: version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(next@16.1.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 2.0.1(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + ai: + specifier: ^6.0.116 + version: 6.0.116(zod@4.3.6) babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 @@ -80,7 +86,7 @@ importers: version: 0.577.0(react@19.2.4) next: specifier: 16.1.7 - version: 16.1.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-mdx-remote: specifier: ^6.0.0 version: 6.0.0(@types/react@19.2.14)(react@19.2.4) @@ -172,6 +178,28 @@ importers: packages: + '@ai-sdk/anthropic@3.0.58': + resolution: {integrity: sha512-/53SACgmVukO4bkms4dpxpRlYhW8Ct6QZRe6sj1Pi5H00hYhxIrqfiLbZBGxkdRvjsBQeP/4TVGsXgH5rQeb8Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/gateway@3.0.66': + resolution: {integrity: sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.19': + resolution: {integrity: sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -697,6 +725,10 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1148,6 +1180,9 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1324,6 +1359,10 @@ packages: vue-router: optional: true + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1338,6 +1377,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ai@6.0.116: + resolution: {integrity: sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1495,6 +1540,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1597,6 +1646,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + lightningcss-android-arm64@1.31.1: resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} engines: {node: '>= 12.0.0'} @@ -2210,6 +2262,30 @@ packages: snapshots: + '@ai-sdk/anthropic@3.0.58(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/gateway@3.0.66(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) + '@vercel/oidc': 3.1.0 + zod: 4.3.6 + + '@ai-sdk/provider-utils@4.0.19(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.3.6 + + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@alloc/quick-lru@5.2.0': {} '@babel/code-frame@7.29.0': @@ -2589,6 +2665,8 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@opentelemetry/api@1.9.0': {} + '@polka/url@1.0.0-next.29': {} '@radix-ui/number@1.1.1': {} @@ -3029,6 +3107,8 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -3151,11 +3231,13 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vercel/analytics@2.0.1(next@16.1.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + '@vercel/analytics@2.0.1(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': optionalDependencies: - next: 16.1.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 + '@vercel/oidc@3.1.0': {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -3166,6 +3248,14 @@ snapshots: acorn@8.15.0: {} + ai@6.0.116(zod@4.3.6): + dependencies: + '@ai-sdk/gateway': 3.0.66(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) + '@opentelemetry/api': 1.9.0 + zod: 4.3.6 + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -3331,6 +3421,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + eventsource-parser@3.0.6: {} + extend@3.0.2: {} fast-content-type-parse@3.0.0: {} @@ -3445,6 +3537,8 @@ snapshots: js-tokens@4.0.0: {} + json-schema@0.4.0: {} + lightningcss-android-arm64@1.31.1: optional: true @@ -3982,7 +4076,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.1.7(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.1.7 '@swc/helpers': 0.5.15 @@ -4001,6 +4095,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.1.7 '@next/swc-win32-arm64-msvc': 16.1.7 '@next/swc-win32-x64-msvc': 16.1.7 + '@opentelemetry/api': 1.9.0 babel-plugin-react-compiler: 1.0.0 sharp: 0.34.5 transitivePeerDependencies: From c6ef2ca9926714ae762d2aae9a41c013b8a6b441 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 08:11:31 +0000 Subject: [PATCH 2/4] feat(terminal): add AI-powered commands with streaming responses Integrate Vercel AI SDK with Anthropic Claude to add intelligent terminal commands: `ask`, `explain`, `summarize`, and `tldr`. Key changes: - API route (app/api/ai/chat) with streaming text responses - Rich system prompt as AIt (Mateo's digital alter ego) with live Spotify/GitHub context injection - Command system extended to support arguments and streaming output - Terminal state manages streaming text with real-time chunk rendering - Input blocked during active AI streaming - Commands registered under new "AI" group in help output Usage: `ask what tech stack do you use?`, `tldr your open source work`, `explain your approach to testing`, `summarize` Co-Authored-By: Claude Opus 4.6 https://claude.ai/code/session_01FXuX3ypbwddUC3rFvYTneT --- .env.local.example | 3 + app/api/ai/chat/route.ts | 28 ++++ components/mate/terminal/command-context.tsx | 4 +- components/mate/terminal/commands/ai.ts | 140 ++++++++++++++++++ components/mate/terminal/commands/index.ts | 2 + .../terminal/hooks/use-command-executor.ts | 14 +- .../terminal/hooks/use-command-runner.tsx | 21 ++- .../mate/terminal/hooks/use-terminal-input.ts | 6 +- .../mate/terminal/hooks/use-terminal-state.ts | 11 ++ components/mate/terminal/terminal.tsx | 40 ++++- components/mate/terminal/types/commands.ts | 6 +- components/mate/terminal/utils/formatting.ts | 4 +- lib/ai/system-prompt.ts | 111 ++++++++++++++ 13 files changed, 370 insertions(+), 20 deletions(-) create mode 100644 app/api/ai/chat/route.ts create mode 100644 components/mate/terminal/commands/ai.ts create mode 100644 lib/ai/system-prompt.ts diff --git a/.env.local.example b/.env.local.example index 8c25739a..78d9b072 100644 --- a/.env.local.example +++ b/.env.local.example @@ -15,5 +15,8 @@ NEXT_PUBLIC_GISCUS_REPO_ID= NEXT_PUBLIC_GISCUS_CATEGORY= NEXT_PUBLIC_GISCUS_CATEGORY_ID= +# AI (Anthropic) +ANTHROPIC_API_KEY= + # Google Tag Manager NEXT_PUBLIC_GTM_ID= diff --git a/app/api/ai/chat/route.ts b/app/api/ai/chat/route.ts new file mode 100644 index 00000000..cd43c270 --- /dev/null +++ b/app/api/ai/chat/route.ts @@ -0,0 +1,28 @@ +import { anthropic } from '@ai-sdk/anthropic'; +import { streamText } from 'ai'; +import { type AIContext, buildSystemPrompt } from '@/lib/ai/system-prompt'; + +export const maxDuration = 30; + +export async function POST(req: Request) { + const body = await req.json(); + const { prompt, context } = body as { prompt: string; context?: AIContext }; + + if (!prompt || typeof prompt !== 'string') { + return new Response('Missing prompt', { status: 400 }); + } + + if (prompt.length > 500) { + return new Response('Prompt too long (max 500 chars)', { status: 400 }); + } + + const result = streamText({ + model: anthropic('claude-sonnet-4-20250514'), + system: buildSystemPrompt(context), + prompt, + maxOutputTokens: 300, + temperature: 0.7, + }); + + return result.toTextStreamResponse(); +} diff --git a/components/mate/terminal/command-context.tsx b/components/mate/terminal/command-context.tsx index 6f74db69..4d71b710 100644 --- a/components/mate/terminal/command-context.tsx +++ b/components/mate/terminal/command-context.tsx @@ -30,6 +30,8 @@ export type DataSources = { export interface TerminalTools { clearLines: () => void; + appendStreamingText: (chunk: string) => void; + finalizeStream: () => void; } export interface CommandContextType { @@ -42,7 +44,7 @@ const CommandContext = createContext({ spotify: { data: null }, github: { data: null }, } as DataSources, - tools: { clearLines: () => {} }, + tools: { clearLines: () => {}, appendStreamingText: () => {}, finalizeStream: () => {} }, }); export const useCommandContext = () => useContext(CommandContext); diff --git a/components/mate/terminal/commands/ai.ts b/components/mate/terminal/commands/ai.ts new file mode 100644 index 00000000..ec0a2c7c --- /dev/null +++ b/components/mate/terminal/commands/ai.ts @@ -0,0 +1,140 @@ +import type { AIContext } from '@/lib/ai/system-prompt'; +import type { DataSources } from '../command-context'; +import type { Command, CommandContext } from '../types/commands'; + +function buildContextFromDataSources(dataSources: DataSources): AIContext { + const context: AIContext = {}; + + const spotifyData = dataSources.spotify.data; + if (spotifyData) { + context.spotify = { + currentlyPlaying: spotifyData.currentlyPlaying + ? { + title: spotifyData.currentlyPlaying.title, + artist: spotifyData.currentlyPlaying.artist, + album: spotifyData.currentlyPlaying.album, + } + : null, + recentTracks: spotifyData.recentlyPlayed?.slice(0, 5).map((t) => ({ title: t.title, artist: t.artist })), + topTracks: spotifyData.topTracks?.slice(0, 5).map((t) => ({ title: t.title, artist: t.artist })), + topArtists: spotifyData.topArtists?.slice(0, 5).map((a) => ({ name: a.name, genres: a.genres })), + }; + } + + const githubData = dataSources.github.data; + if (githubData) { + context.github = { + login: githubData.profile?.login, + followers: githubData.profile?.followers?.length, + activities: githubData.activities?.activities?.slice(0, 5).map((a) => ({ + type: a.type, + title: a.title, + repo: a.repo.fullName, + date: a.date, + })), + }; + } + + return context; +} + +async function streamAIResponse( + prompt: string, + { dataSources, tools }: Pick, +): Promise { + const context = buildContextFromDataSources(dataSources); + + const response = await fetch('/api/ai/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt, context }), + }); + + if (!response.ok) { + const errorText = await response.text(); + tools.appendStreamingText(`Error: ${errorText || response.statusText}`); + tools.finalizeStream(); + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + tools.appendStreamingText('Error: No response stream.'); + tools.finalizeStream(); + return; + } + + const decoder = new TextDecoder(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + tools.appendStreamingText(chunk); + } + } finally { + tools.finalizeStream(); + } +} + +export const askCommand: Command = { + name: 'ask', + description: 'Ask AIt anything', + usage: 'ask ', + aliases: ['ai', 'chat', 'q'], + handler: async ({ args, dataSources, tools }) => { + if (!args) return 'Usage: ask \nExample: ask what tech stack do you use?'; + + tools.appendStreamingText(''); + await streamAIResponse(args, { dataSources, tools }); + return { type: 'streamed' }; + }, +}; + +export const explainCommand: Command = { + name: 'explain', + description: "Explain a topic from Mateo's perspective", + usage: 'explain ', + aliases: ['ex'], + handler: async ({ args, dataSources, tools }) => { + if (!args) return 'Usage: explain \nExample: explain your approach to testing'; + + const prompt = `Explain the following from your perspective and experience as a senior engineer: ${args}`; + tools.appendStreamingText(''); + await streamAIResponse(prompt, { dataSources, tools }); + return { type: 'streamed' }; + }, +}; + +export const summarizeCommand: Command = { + name: 'summarize', + description: 'Summarize what Mateo is about', + usage: 'summarize [topic]', + aliases: ['sum'], + handler: async ({ args, dataSources, tools }) => { + const prompt = args + ? `Give a focused summary about: ${args}. Draw from your experience and projects.` + : "Give a concise summary of who you are, what you do, and what you're working on right now."; + + tools.appendStreamingText(''); + await streamAIResponse(prompt, { dataSources, tools }); + return { type: 'streamed' }; + }, +}; + +export const tldrCommand: Command = { + name: 'tldr', + description: 'Quick take on any topic', + usage: 'tldr ', + handler: async ({ args, dataSources, tools }) => { + if (!args) return 'Usage: tldr \nExample: tldr your open source work'; + + const prompt = `Give me a very brief, 2-3 line tldr about: ${args}`; + tools.appendStreamingText(''); + await streamAIResponse(prompt, { dataSources, tools }); + return { type: 'streamed' }; + }, +}; + +export const aiCommands: Command[] = [askCommand, explainCommand, summarizeCommand, tldrCommand]; diff --git a/components/mate/terminal/commands/index.ts b/components/mate/terminal/commands/index.ts index 3a117d9f..ed410410 100644 --- a/components/mate/terminal/commands/index.ts +++ b/components/mate/terminal/commands/index.ts @@ -1,4 +1,5 @@ import { registry } from '../command-registry'; +import { aiCommands } from './ai'; import { aitCommand } from './ait'; import { linksCommand, siteCommand } from './navigation'; import { personalCommands } from './personal'; @@ -9,6 +10,7 @@ registry.registerGroup('System', systemCommands); registry.registerGroup('Personal', personalCommands); registry.registerGroup('Social', socialCommands); registry.registerGroup('Navigation', [linksCommand, siteCommand]); +registry.registerGroup('AI', aiCommands); registry.registerGroup('Fun', [aitCommand]); export { registry as commandRegistry }; diff --git a/components/mate/terminal/hooks/use-command-executor.ts b/components/mate/terminal/hooks/use-command-executor.ts index 5f9ff35c..9634f4fc 100644 --- a/components/mate/terminal/hooks/use-command-executor.ts +++ b/components/mate/terminal/hooks/use-command-executor.ts @@ -20,11 +20,15 @@ export function useCommandExecutor({ dataSources, tools, actions }: CommandExecu const commandName = input.trim().split(' ')[0]; trackTerminal.commandExecuted(commandName, result.success !== false); - const newLines = [ - { text: input, showPrompt: true }, - ...result.output.split('\n').map((text) => ({ text, showPrompt: false })), - ]; - actions.addCompletedLines(newLines); + if (result.streamed) { + actions.addCompletedLines([{ text: input, showPrompt: true }]); + } else { + const newLines = [ + { text: input, showPrompt: true }, + ...result.output.split('\n').map((text) => ({ text, showPrompt: false })), + ]; + actions.addCompletedLines(newLines); + } actions.addToHistory(input); }, [runCommand, actions], diff --git a/components/mate/terminal/hooks/use-command-runner.tsx b/components/mate/terminal/hooks/use-command-runner.tsx index 7beefdfe..0b1db476 100644 --- a/components/mate/terminal/hooks/use-command-runner.tsx +++ b/components/mate/terminal/hooks/use-command-runner.tsx @@ -2,9 +2,10 @@ import { useCallback, useMemo } from 'react'; import type { DataSources, TerminalTools } from '../command-context'; import { commandRegistry } from '../commands/index'; -type CommandResult = { +export type CommandResult = { success: boolean; output: string; + streamed?: boolean; }; interface CommandRunnerOptions { @@ -17,8 +18,13 @@ export function useCommandRunner({ dataSources, tools }: CommandRunnerOptions) { const runCommand = useCallback( async (input: string): Promise => { - const trimmedInput = input.trim().toLowerCase(); - const command = commandMap.get(trimmedInput); + const trimmedInput = input.trim(); + const spaceIndex = trimmedInput.indexOf(' '); + const commandName = + spaceIndex === -1 ? trimmedInput.toLowerCase() : trimmedInput.slice(0, spaceIndex).toLowerCase(); + const args = spaceIndex === -1 ? undefined : trimmedInput.slice(spaceIndex + 1).trim() || undefined; + + const command = commandMap.get(commandName); if (!command) { return { @@ -28,8 +34,13 @@ export function useCommandRunner({ dataSources, tools }: CommandRunnerOptions) { } try { - const output = await command.handler({ dataSources, tools }); - return { success: true, output }; + const result = await command.handler({ dataSources, tools, args }); + + if (typeof result === 'object' && result.type === 'streamed') { + return { success: true, output: '', streamed: true }; + } + + return { success: true, output: result as string }; } catch (error) { console.error('Error executing command:', error); return { diff --git a/components/mate/terminal/hooks/use-terminal-input.ts b/components/mate/terminal/hooks/use-terminal-input.ts index 46a42a0f..000ec949 100644 --- a/components/mate/terminal/hooks/use-terminal-input.ts +++ b/components/mate/terminal/hooks/use-terminal-input.ts @@ -3,7 +3,7 @@ import { useCallback } from 'react'; import type { TerminalState, TerminalStateActions } from './use-terminal-state'; interface TerminalInputOptions { - state: Pick; + state: Pick; actions: Pick; executeCommand: (input: string) => Promise; getMatchingCommands: (input: string) => string[]; @@ -12,7 +12,9 @@ interface TerminalInputOptions { export function useTerminalInput({ state, actions, executeCommand, getMatchingCommands }: TerminalInputOptions) { const handleUserInput = useCallback( (e: KeyboardEvent) => { - const { userInput, commandHistory, historyIndex } = state; + const { userInput, commandHistory, historyIndex, streamingText } = state; + + if (streamingText !== null) return; switch (e.key) { case 'Enter': diff --git a/components/mate/terminal/hooks/use-terminal-state.ts b/components/mate/terminal/hooks/use-terminal-state.ts index 332a42d2..99ce22f8 100644 --- a/components/mate/terminal/hooks/use-terminal-state.ts +++ b/components/mate/terminal/hooks/use-terminal-state.ts @@ -10,6 +10,7 @@ export interface TerminalState { userInput: string; commandHistory: string[]; historyIndex: number; + streamingText: string | null; } export interface TerminalStateActions { @@ -22,6 +23,8 @@ export interface TerminalStateActions { setUserInput: (input: string) => void; addToHistory: (command: string) => void; updateHistoryIndex: (index: number) => void; + setStreamingText: (text: string | null) => void; + appendStreamingText: (chunk: string) => void; } export function useTerminalState(_initialMessages: string[]): [TerminalState, TerminalStateActions] { @@ -32,6 +35,7 @@ export function useTerminalState(_initialMessages: string[]): [TerminalState, Te const [userInput, setUserInput] = useState(''); const [commandHistory, setCommandHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); + const [streamingText, setStreamingText] = useState(null); const addCompletedLine = useCallback((line: TerminalLine) => { setCompletedLines((prev) => [...prev, line].slice(-100)); @@ -50,6 +54,10 @@ export function useTerminalState(_initialMessages: string[]): [TerminalState, Te setHistoryIndex(-1); }, []); + const appendStreamingText = useCallback((chunk: string) => { + setStreamingText((prev) => (prev === null ? chunk : prev + chunk)); + }, []); + return [ { currentLine, @@ -59,6 +67,7 @@ export function useTerminalState(_initialMessages: string[]): [TerminalState, Te userInput, commandHistory, historyIndex, + streamingText, }, { setCurrentLine, @@ -70,6 +79,8 @@ export function useTerminalState(_initialMessages: string[]): [TerminalState, Te setUserInput, addToHistory, updateHistoryIndex: setHistoryIndex, + setStreamingText, + appendStreamingText, }, ]; } diff --git a/components/mate/terminal/terminal.tsx b/components/mate/terminal/terminal.tsx index cd1bef88..039f10c8 100644 --- a/components/mate/terminal/terminal.tsx +++ b/components/mate/terminal/terminal.tsx @@ -33,7 +33,7 @@ export function Terminal({ const commandCount = useRef(0); const [state, actions] = useTerminalState(initialMessages); - const { currentLine, typingLine, completedLines, isComplete, userInput } = state; + const { currentLine, typingLine, completedLines, isComplete, userInput, streamingText } = state; const terminalRef = useRef(null); const inputRef = useRef(null); @@ -75,7 +75,27 @@ export function Terminal({ ], ); - const tools = useMemo(() => ({ clearLines: actions.clearCompletedLines }), [actions]); + const isStreaming = streamingText !== null; + const streamingTextRef = useRef(null); + streamingTextRef.current = streamingText; + + const finalizeStream = useCallback(() => { + const text = streamingTextRef.current; + if (text !== null) { + const lines = text.split('\n').map((t) => ({ text: t, showPrompt: false })); + actions.addCompletedLines(lines); + } + actions.setStreamingText(null); + }, [actions]); + + const tools = useMemo( + () => ({ + clearLines: actions.clearCompletedLines, + appendStreamingText: actions.appendStreamingText, + finalizeStream, + }), + [actions, finalizeStream], + ); const { executeCommand: baseExecuteCommand, getMatchingCommands } = useCommandExecutor({ dataSources, @@ -184,7 +204,7 @@ export function Terminal({ }; debouncedScroll(); return () => clearTimeout(timeoutId); - }, [completedLines, typingLine, scrollToBottom]); + }, [completedLines, typingLine, streamingText, scrollToBottom]); const handleTerminalClick = useCallback(() => { if (isComplete && inputRef.current) { @@ -224,7 +244,8 @@ export function Terminal({ {!isComplete && !skipAnimations && typingLine.currentIndex > 0 && ( )} - {isComplete && ( + {isStreaming && } + {isComplete && !isStreaming && ( +

+ {text} + +

+ + ); +}); + const MemoizedLine = memo(Line); const TerminalHeader = memo(function TerminalHeader() { return ( diff --git a/components/mate/terminal/types/commands.ts b/components/mate/terminal/types/commands.ts index b73cb302..d65fe61d 100644 --- a/components/mate/terminal/types/commands.ts +++ b/components/mate/terminal/types/commands.ts @@ -3,13 +3,17 @@ import type { DataSources, TerminalTools } from '../command-context'; export interface CommandContext { dataSources: DataSources; tools: TerminalTools; + args?: string; } +export type CommandHandlerResult = string | { type: 'streamed' }; + export interface Command { name: string; description: string; - handler: (context: CommandContext) => Promise | string; + handler: (context: CommandContext) => Promise | CommandHandlerResult; aliases?: string[]; + usage?: string; } export interface CommandGroup { diff --git a/components/mate/terminal/utils/formatting.ts b/components/mate/terminal/utils/formatting.ts index 7a3aac3a..0a44ef13 100644 --- a/components/mate/terminal/utils/formatting.ts +++ b/components/mate/terminal/utils/formatting.ts @@ -1,8 +1,8 @@ import type { Command, CommandGroup } from '../types/commands'; export const formatCommand = (cmd: Command): string => { - const nameAndAliases = cmd.aliases?.length ? `${cmd.name} (${cmd.aliases.join(', ')})` : cmd.name; - const paddedName = nameAndAliases.padEnd(20); + const label = cmd.usage || (cmd.aliases?.length ? `${cmd.name} (${cmd.aliases.join(', ')})` : cmd.name); + const paddedName = label.padEnd(28); return ` ${paddedName} - ${cmd.description}`; }; diff --git a/lib/ai/system-prompt.ts b/lib/ai/system-prompt.ts new file mode 100644 index 00000000..8abb0f15 --- /dev/null +++ b/lib/ai/system-prompt.ts @@ -0,0 +1,111 @@ +import personal from '@/lib/config/personal'; + +export interface AIContext { + spotify?: { + currentlyPlaying?: { title: string; artist: string; album: string } | null; + recentTracks?: { title: string; artist: string }[]; + topTracks?: { title: string; artist: string }[]; + topArtists?: { name: string; genres?: string[] }[]; + }; + github?: { + login?: string; + followers?: number; + activities?: { type: string; title: string; repo: string; date: string }[]; + }; +} + +function buildSpotifyContext(spotify: AIContext['spotify']): string { + if (!spotify) return ''; + + const parts: string[] = []; + + if (spotify.currentlyPlaying) { + parts.push( + `Currently listening to: "${spotify.currentlyPlaying.title}" by ${spotify.currentlyPlaying.artist} (${spotify.currentlyPlaying.album})`, + ); + } + + if (spotify.recentTracks?.length) { + const tracks = spotify.recentTracks + .slice(0, 5) + .map((t) => `"${t.title}" by ${t.artist}`) + .join(', '); + parts.push(`Recent tracks: ${tracks}`); + } + + if (spotify.topArtists?.length) { + const artists = spotify.topArtists + .slice(0, 5) + .map((a) => a.name) + .join(', '); + parts.push(`Top artists: ${artists}`); + } + + return parts.length ? `\n\nLive Spotify data:\n${parts.join('\n')}` : ''; +} + +function buildGitHubContext(github: AIContext['github']): string { + if (!github) return ''; + + const parts: string[] = []; + + if (github.login) { + parts.push(`GitHub: @${github.login}`); + } + if (github.followers) { + parts.push(`Followers: ${github.followers}`); + } + + if (github.activities?.length) { + const activities = github.activities + .slice(0, 5) + .map((a) => `${a.type}: ${a.title} (${a.repo})`) + .join(', '); + parts.push(`Recent activity: ${activities}`); + } + + return parts.length ? `\n\nLive GitHub data:\n${parts.join('\n')}` : ''; +} + +export function buildSystemPrompt(context?: AIContext): string { + const spotifySection = buildSpotifyContext(context?.spotify); + const githubSection = buildGitHubContext(context?.github); + + return `You are AIt — Mateo Nunez's digital alter ego, embedded in the terminal of his personal website (mateonunez.co). + +## Who you are +You speak as Mateo's AI counterpart. You're sharp, direct, warm but never cheesy. You match Mateo's personality: technically rigorous, a bit irreverent, Colombian humor, zero fluff. You use concise, terminal-friendly language — no markdown headers, no bullet lists, no verbose paragraphs. Keep responses short (2-6 lines for simple questions, up to 12 for complex ones). You can use emojis sparingly like Mateo does. + +## About Mateo +- ${personal.bio.full} +- Location: ${personal.location.display} +- Title: ${personal.title} / ${personal.alternativeTitle} +- Languages spoken: ${personal.languagesSpoken.join(', ')} +- Currently: ${personal.currentWork.join('; ')} + +## Technical skills +- Languages: ${personal.skills.languages.join(', ')} +- Frameworks: ${personal.skills.frameworks.join(', ')} +- Cloud & DevOps: ${personal.skills.cloud.join(', ')} +- Databases: ${personal.skills.databases.join(', ')} +- AI: ${personal.skills.ai.join(', ')} + +## Site sections +- / — Homepage with terminal, about, latest articles, Spotify integration +- /blog — Technical articles on Node.js, Next.js, testing, and more +- /open-source — Open source projects and GitHub activity +- /spotify — Full Spotify profile, playlists, top tracks and artists + +## Bookshelf +${personal.bookshelf.map((b) => `- "${b.label}" by ${b.author}: ${b.description}`).join('\n')} +${spotifySection}${githubSection} + +## Rules +- You respond as AIt, Mateo's alter ego — not as a generic assistant. +- Never break character. You ARE AIt. +- Format for terminal output: plain text, no markdown. Use line breaks for structure. +- If asked about something you genuinely don't know about Mateo, say so honestly. +- You can reference live Spotify/GitHub data when relevant. +- If someone asks to contact Mateo, share: ${personal.email} +- Keep it real. No corporate speak. No filler. Direct and human.`; +} From 3f2fc1ade81fce0735fd89efed943ae36ee10cba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 08:12:14 +0000 Subject: [PATCH 3/4] chore: fix trailing newline in manifest.json Co-Authored-By: Claude Opus 4.6 https://claude.ai/code/session_01FXuX3ypbwddUC3rFvYTneT --- app/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/manifest.json b/app/manifest.json index ca75abbf..dbef89fb 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -33,4 +33,4 @@ "purpose": "any maskable" } ] -} +} \ No newline at end of file From c94e10d6661096dfa7f4a541ebcafd235fc57286 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Mar 2026 08:38:44 +0000 Subject: [PATCH 4/4] fix(terminal): allow space key input when typing in terminal The outer div's keydown handler was intercepting space keypresses (to focus the input) even when the input already had focus, preventing users from typing spaces. Skip the handler when the event originates from the input element. Co-Authored-By: Claude Opus 4.6 https://claude.ai/code/session_01FXuX3ypbwddUC3rFvYTneT --- components/mate/terminal/terminal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/components/mate/terminal/terminal.tsx b/components/mate/terminal/terminal.tsx index 039f10c8..b67c3e54 100644 --- a/components/mate/terminal/terminal.tsx +++ b/components/mate/terminal/terminal.tsx @@ -215,6 +215,7 @@ export function Terminal({ const handleTerminalKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (e.target instanceof HTMLInputElement) return; if (isComplete && inputRef.current && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); inputRef.current.focus();