mirror of
https://github.com/hanzoai/cc.git
synced 2026-09-24 16:57:40 +00:00
Input pattern highlighters (#387)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
parent
cdcbeb5cb9
commit
55f87d33db
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Add input pattern highlighters (#387) - @bl-ue
|
||||
|
||||
## [v3.3.0](https://github.com/Piebald-AI/tweakcc/releases/tag/v3.3.0) - 2026-01-18
|
||||
|
||||
- Suppress /rate-limit-options from being triggered when rate limits are hit (#358) - @basekevin
|
||||
|
||||
@@ -47,6 +47,7 @@ With tweakcc, you can
|
||||
|
||||
- Customize all of Claude Code's **system prompts** (**NEW:** also see all of [**Claude Code's system prompts**](https://github.com/Piebald-AI/claude-code-system-prompts))
|
||||
- Create custom **toolsets** that can be used in Claude Code with the new **`/toolset`** command
|
||||
- **Highlight** custom patterns while you type in the CC input box with custom colors and styling, like how `ultrathink` used to be rainbow-highlighted.
|
||||
- Manually name **sessions** in Claude Code with `/title my chat name` or `/rename` (see [**our blog post**](https://piebald.ai/blog/messages-as-commits-claude-codes-git-like-dag-of-conversations) for implementation details)
|
||||
- Create **custom themes** with a graphical HSL/RGB color picker
|
||||
- Add custom **thinking verbs** that will show while Claude's working
|
||||
@@ -78,13 +79,91 @@ $ npx tweakcc
|
||||
$ pnpm dlx tweakcc
|
||||
```
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [How it works](#how-it-works)
|
||||
- [**Features**](#features)
|
||||
- [Input pattern highlighters](#input-pattern-highlighters)
|
||||
- [Configuration directory](#configuration-directory)
|
||||
- [Building from source](#building-from-source)
|
||||
- [Related projects](#related-projects)
|
||||
- [System prompts](#system-prompts)
|
||||
- [Toolsets](#toolsets)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [FAQ](#faq)
|
||||
- [License](#license)
|
||||
|
||||
## How it works
|
||||
|
||||
tweakcc works by patching Claude Code's minified `cli.js` file. For npm-based installations this file is modified directly, but for native installation it's extracted from the binary, patched, and then the binary is repacked. When you update your Claude Code installation, your customizations will be overwritten, but they're remembered in your configuration file, so they can be reapplied by just running `npx tweakcc --apply`.
|
||||
|
||||
tweakcc is verified to work with Claude Code **2.1.2.** In newer or earlier versions various patches might not work. However, if we have the system prompts for your version (check [here](https://github.com/Piebald-AI/tweakcc/tree/main/data/prompts)) then system prompt patching is guaranteed to work with that version, even if it's significantly different from the verified CC version. We get the latest system prompts within minutes of each new CC release, so unless you're using a CC version older than 2.0.14, your version is supported.
|
||||
tweakcc is verified to work with Claude Code **2.1.2.** In newer or earlier versions various patches might not work. However, if we have the [system prompts for your version](https://github.com/Piebald-AI/tweakcc/tree/main/data/prompts) then system prompt patching is guaranteed to work with that version, even if it's significantly different from the verified CC version. We get the latest system prompts within minutes of each new CC release, so unless you're using a CC version older than 2.0.14, your version is supported.
|
||||
|
||||
### Configuration directory
|
||||
## Features
|
||||
|
||||
_More feature documentation coming soon._
|
||||
|
||||
### Input pattern highlighters
|
||||
|
||||
For a few weeks, when you typed the word "ultrathink" into the Claude Code input box, it would be highlighted rainbow. That's gone now, but the underlying highlighting infrastructure is still present in Claude Code today, and tweakcc lets you specify custom highlighters comprised of a **regular expression**, **format string**, and **colors & styling**.
|
||||
|
||||
Here's a demo where every word is assigned a different color based on its first letter:
|
||||
|
||||

|
||||
|
||||
Here's one where various common patterns like environment variables, file paths, numbers, and markdown constructs are highlighted:
|
||||
|
||||

|
||||
|
||||
Finally, here's one showing how you can render extra characters that aren't really part of the prompt by customizing the **format string**. The first line shows a copy of what I've actually got typed into the prompt, and in the prompt itself you can see that `cluade` was _visually_ (but not _in reality_) replaced with `Claude Code, ...`, etc.
|
||||
|
||||

|
||||
To add some patterns, you can use the tweakcc UI or edit [`~/.tweakcc/config.json`](#configuration-directory) manually.
|
||||
|
||||
**Via the UI:**
|
||||
|
||||
| Listing | Edit |
|
||||
| ---------------------------------------------------- | ------------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
**Via `config.json`:**
|
||||
|
||||
In `.settings.inputPatternHighlighters` (an array), add a new object:
|
||||
|
||||
```json
|
||||
"inputPatternHighlighters": [
|
||||
...
|
||||
{
|
||||
"name": "File path",
|
||||
"regex": "(?:[a-zA-Z]:)?[/\\\\]?[a-zA-Z0-9._\\-]+(?:[/\\\\][a-zA-Z0-9._\\-]+)+",
|
||||
"regexFlags": "g",
|
||||
"format": "{MATCH}",
|
||||
"styling": [
|
||||
"bold"
|
||||
],
|
||||
"foregroundColor": "rgb(71,194,10)",
|
||||
"backgroundColor": null,
|
||||
"enabled": true
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
Here's the schema for the object format:
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: string; // User-friendly name
|
||||
regex: string; // Regex pattern (stored as string)
|
||||
regexFlags: string; // Flags for the regex, must include 'g' for matchAll
|
||||
format: string; // Format string, use {MATCH} as placeholder
|
||||
styling: string[]; // ['bold', 'italic', 'underline', 'strikethrough', 'inverse']
|
||||
foregroundColor: string | null; // null = don't specify, otherwise rgb(r,g,b)
|
||||
backgroundColor: string | null; // null = don't specify, otherwise rgb(r,g,b)
|
||||
enabled: boolean; // Temporarily disable this pattern
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration directory
|
||||
|
||||
tweakcc stores its configuration files in one of the following locations, in order of priority:
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -764,4 +764,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
explore: null,
|
||||
generalPurpose: null,
|
||||
},
|
||||
inputPatternHighlighters: [],
|
||||
inputPatternHighlightersTestText: 'Type test text here to see highlighting',
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ import { writeThinkerSymbolSpeed } from './thinkerSymbolSpeed';
|
||||
import { writeThinkerSymbolWidthLocation } from './thinkerSymbolWidth';
|
||||
import { writeThinkerVerbs } from './thinkerVerbs';
|
||||
import { writeUserMessageDisplay } from './userMessageDisplay';
|
||||
import { writeInputPatternHighlighters } from './inputPatternHighlighters';
|
||||
import { writeVerboseProperty } from './verboseProperty';
|
||||
import { writeModelCustomizations } from './modelSelector';
|
||||
import { writeThinkingVisibility } from './thinkingVisibility';
|
||||
@@ -565,6 +566,21 @@ export const applyCustomization = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Apply input pattern highlighters
|
||||
if (
|
||||
config.settings.inputPatternHighlighters &&
|
||||
config.settings.inputPatternHighlighters.length > 0
|
||||
) {
|
||||
if (
|
||||
(result = writeInputPatternHighlighters(
|
||||
content,
|
||||
config.settings.inputPatternHighlighters
|
||||
))
|
||||
) {
|
||||
content = result;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply input box border customization
|
||||
if (
|
||||
config.settings.inputBox &&
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
// Input Pattern Highlighters Patch
|
||||
// This patch adds custom syntax highlighting to the user's input prompt.
|
||||
// Users can define regex patterns that match text in their typing area
|
||||
// and style them with custom colors, text styles, and format strings.
|
||||
|
||||
import { stringifyRegex } from '@/utils';
|
||||
import { InputPatternHighlighter } from '../types';
|
||||
import { findChalkVar, LocationResult, showDiff, escapeIdent } from './index';
|
||||
|
||||
/**
|
||||
* Find the location where we need to add the custom match type rendering.
|
||||
* This finds the "solid" type renderer and returns info for inserting after it.
|
||||
*
|
||||
* Pattern: else if(X.type==="solid")return Y.createElement(Z,{key:K,color:X.color},Y.createElement(T,null,M.text));
|
||||
*/
|
||||
const findSolidMatchRendererLocation = (
|
||||
oldFile: string
|
||||
): LocationResult | null => {
|
||||
const pattern =
|
||||
/else if\(([$\w]+)\.type==="solid"\)return ([$\w]+)\.createElement\(([$\w]+),\{key:([$\w]+),color:[$\w]+\.color\},[$\w]+\.createElement\([$\w]+,null,([$\w]+)\.text\)\);/;
|
||||
|
||||
const match = oldFile.match(pattern);
|
||||
if (!match || match.index === undefined) {
|
||||
console.error(
|
||||
'patch: inputPatternHighlighters: failed to find solid match renderer pattern'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startIndex: match.index + match[0].length,
|
||||
endIndex: match.index + match[0].length,
|
||||
identifiers: [
|
||||
match[1], // matchDefinitionVar (the style object, e.g., X)
|
||||
match[2], // reactVar (e.g., Y)
|
||||
match[3], // textComponentVar (e.g., Z)
|
||||
match[4], // keyVar (e.g., K)
|
||||
match[5], // matchVar (the match object with .text, e.g., M)
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the start of the match pushes section and return both the location and the ranges variable.
|
||||
*
|
||||
* Pattern: X.push({start:...,end:...,style:{type:"solid",color:"warning"}
|
||||
* Returns the match index and the variable name (X = rangesVar)
|
||||
*/
|
||||
const findStartOfMatchPushes = (
|
||||
oldFile: string
|
||||
): { index: number; rangesVar: string } | null => {
|
||||
const pattern =
|
||||
/\b([$\w]+)\.push\(\{start:[$\w]+,end:[$\w]+\+[$\w]+\.length,style:\{type:"solid",color:"warning"\}/;
|
||||
|
||||
const match = oldFile.match(pattern);
|
||||
if (!match || match.index === undefined) {
|
||||
console.error(
|
||||
'patch: inputPatternHighlighters: failed to find match pushes pattern'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
index: match.index,
|
||||
rangesVar: match[1],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the React useMemo variable by looking back from the match pushes location.
|
||||
*/
|
||||
const findReactVarFromUseMemo = (
|
||||
oldFile: string,
|
||||
searchEndIndex: number
|
||||
): string | null => {
|
||||
// Look back 100 chars from searchEndIndex
|
||||
const searchStart = Math.max(0, searchEndIndex - 100);
|
||||
const searchWindow = oldFile.slice(searchStart, searchEndIndex);
|
||||
|
||||
// Find the LAST instance of useMemo pattern
|
||||
const pattern = /\b[$\w]+=([$\w]+(?:\.default)?)\.useMemo\(/g;
|
||||
let lastMatch: RegExpExecArray | null = null;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(searchWindow)) !== null) {
|
||||
lastMatch = match;
|
||||
}
|
||||
|
||||
if (!lastMatch) {
|
||||
console.error(
|
||||
'patch: inputPatternHighlighters: failed to find useMemo pattern'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return lastMatch[1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the input variable by looking back from the match pushes location.
|
||||
*/
|
||||
const findInputVar = (
|
||||
oldFile: string,
|
||||
searchEndIndex: number
|
||||
): string | null => {
|
||||
// Look back 4000 chars from searchEndIndex
|
||||
const searchStart = Math.max(0, searchEndIndex - 4000);
|
||||
const searchWindow = oldFile.slice(searchStart, searchEndIndex);
|
||||
|
||||
// Find input:X pattern
|
||||
const pattern = /\binput:([$\w]+),/;
|
||||
const match = searchWindow.match(pattern);
|
||||
|
||||
if (!match) {
|
||||
console.error(
|
||||
'patch: inputPatternHighlighters: failed to find input variable pattern'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return match[1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the location to insert the useMemo hooks.
|
||||
* We need to find a spot in the variable declarations before the match pushes.
|
||||
* Look for the pattern where useMemo is being used and insert after the last one.
|
||||
*/
|
||||
const findUseMemoInsertLocation = (
|
||||
oldFile: string,
|
||||
matchPushesIndex: number
|
||||
): LocationResult | null => {
|
||||
const reactVar = findReactVarFromUseMemo(oldFile, matchPushesIndex);
|
||||
if (!reactVar) return null;
|
||||
|
||||
const inputVar = findInputVar(oldFile, matchPushesIndex);
|
||||
if (!inputVar) return null;
|
||||
|
||||
// Look back from matchPushesIndex to find the last useMemo call
|
||||
// We want to insert AFTER the last useMemo's closing bracket and comma
|
||||
const searchStart = Math.max(0, matchPushesIndex - 500);
|
||||
const searchWindow = oldFile.slice(searchStart, matchPushesIndex);
|
||||
|
||||
// Find the last useMemo pattern and where it ends (after the dependency array)
|
||||
// Pattern: varName=reactVar.useMemo(()=>{...},[...]),
|
||||
const useMemoPattern =
|
||||
/\b[$\w]+=([$\w]+(?:\.default)?)\.useMemo\(\(\)=>\{[^}]*\},\[[^\]]*\]\),?/g;
|
||||
let lastMatch: RegExpExecArray | null = null;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = useMemoPattern.exec(searchWindow)) !== null) {
|
||||
lastMatch = match;
|
||||
}
|
||||
|
||||
if (!lastMatch) {
|
||||
console.error(
|
||||
'patch: inputPatternHighlighters: failed to find useMemo insertion point'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Insert after the last useMemo declaration
|
||||
const insertIndex = searchStart + lastMatch.index + lastMatch[0].length;
|
||||
|
||||
return {
|
||||
startIndex: insertIndex,
|
||||
endIndex: insertIndex,
|
||||
identifiers: [reactVar, inputVar],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the location to insert the range pushes (before the return statement).
|
||||
*/
|
||||
const findRangePushesInsertLocation = (
|
||||
oldFile: string,
|
||||
matchPushesIndex: number,
|
||||
rangesVar: string
|
||||
): LocationResult | null => {
|
||||
// Look ahead 600 chars from matchPushesIndex
|
||||
const searchEnd = Math.min(oldFile.length, matchPushesIndex + 600);
|
||||
const searchWindow = oldFile.slice(matchPushesIndex, searchEnd);
|
||||
|
||||
// Find `return rangesVar}` (note: }} because minified, the closing brace of the function)
|
||||
const pattern = new RegExp(`return ${escapeIdent(rangesVar)}}`);
|
||||
const match = searchWindow.match(pattern);
|
||||
|
||||
if (!match || match.index === undefined) {
|
||||
console.error(
|
||||
'patch: inputPatternHighlighters: failed to find return statement'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const insertIndex = matchPushesIndex + match.index;
|
||||
|
||||
return {
|
||||
startIndex: insertIndex,
|
||||
endIndex: insertIndex,
|
||||
identifiers: [rangesVar],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the chalk chain for a highlighter configuration.
|
||||
*/
|
||||
const buildChalkChain = (
|
||||
chalkVar: string,
|
||||
highlighter: InputPatternHighlighter
|
||||
): string => {
|
||||
let chain = chalkVar;
|
||||
|
||||
// Add foreground color
|
||||
if (highlighter.foregroundColor) {
|
||||
const fgMatch = highlighter.foregroundColor.match(/\d+/g);
|
||||
if (fgMatch) {
|
||||
chain += `.rgb(${fgMatch.join(',')})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add background color
|
||||
if (highlighter.backgroundColor) {
|
||||
const bgMatch = highlighter.backgroundColor.match(/\d+/g);
|
||||
if (bgMatch) {
|
||||
chain += `.bgRgb(${bgMatch.join(',')})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add styling
|
||||
if (highlighter.styling.includes('bold')) chain += '.bold';
|
||||
if (highlighter.styling.includes('italic')) chain += '.italic';
|
||||
if (highlighter.styling.includes('underline')) chain += '.underline';
|
||||
if (highlighter.styling.includes('strikethrough')) chain += '.strikethrough';
|
||||
if (highlighter.styling.includes('inverse')) chain += '.inverse';
|
||||
|
||||
return chain;
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply all input pattern highlighter patches.
|
||||
*/
|
||||
export const writeInputPatternHighlighters = (
|
||||
oldFile: string,
|
||||
highlighters: InputPatternHighlighter[]
|
||||
): string | null => {
|
||||
// Filter to only enabled highlighters
|
||||
const enabledHighlighters = highlighters.filter(h => h.enabled);
|
||||
|
||||
if (enabledHighlighters.length === 0) {
|
||||
return null; // No highlighters to apply
|
||||
}
|
||||
|
||||
// Find chalk variable
|
||||
const chalkVar = findChalkVar(oldFile);
|
||||
if (!chalkVar) {
|
||||
console.error(
|
||||
'^ patch: inputPatternHighlighters: failed to find chalk variable'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
let newFile = oldFile;
|
||||
|
||||
// === PATCH 1: Add custom match type rendering ===
|
||||
const solidLocation = findSolidMatchRendererLocation(oldFile);
|
||||
if (!solidLocation) {
|
||||
console.error(
|
||||
'^ patch: inputPatternHighlighters: solidLocation returned null'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [matchDefinitionVar, reactVar, textComponentVar, keyVar, matchVar] =
|
||||
solidLocation.identifiers!;
|
||||
|
||||
const customRendererCode = `else if(${matchDefinitionVar}.type==="custom")return ${reactVar}.createElement(${textComponentVar},{key:${keyVar}},${matchDefinitionVar}.fn(${matchDefinitionVar}.format.replace(/\\{MATCH\\}/g,${matchVar}.text)));`;
|
||||
|
||||
newFile =
|
||||
oldFile.slice(0, solidLocation.startIndex) +
|
||||
customRendererCode +
|
||||
oldFile.slice(solidLocation.endIndex);
|
||||
|
||||
showDiff(
|
||||
oldFile,
|
||||
newFile,
|
||||
customRendererCode,
|
||||
solidLocation.startIndex,
|
||||
solidLocation.endIndex
|
||||
);
|
||||
|
||||
// === PATCH 2: Add useMemo hooks for each highlighter ===
|
||||
// Note: We need to re-find locations since we modified the file
|
||||
const matchPushes = findStartOfMatchPushes(newFile);
|
||||
if (!matchPushes) {
|
||||
console.error(
|
||||
'^ patch: inputPatternHighlighters: matchPushes returned null'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const useMemoLocation = findUseMemoInsertLocation(newFile, matchPushes.index);
|
||||
if (!useMemoLocation) {
|
||||
console.error(
|
||||
'^ patch: inputPatternHighlighters: useMemoLocation returned null'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [reactVarFromMemo, inputVar] = useMemoLocation.identifiers!;
|
||||
|
||||
// Build useMemo hooks for each highlighter
|
||||
let useMemoCode = '';
|
||||
for (let i = 0; i < enabledHighlighters.length; i++) {
|
||||
const highlighter = enabledHighlighters[i];
|
||||
// Ensure the regex has the global flag
|
||||
let flags = highlighter.regexFlags;
|
||||
if (!flags.includes('g')) {
|
||||
flags += 'g';
|
||||
}
|
||||
const regex = new RegExp(highlighter.regex, flags);
|
||||
const regexStr = stringifyRegex(regex);
|
||||
|
||||
useMemoCode += `matchedTweakccReplacements${i}=${reactVarFromMemo}.useMemo(()=>{return[...${inputVar}.matchAll(${regexStr})].map(m=>({start:m.index,end:m.index+m[0].length}))},[${inputVar}]),`;
|
||||
}
|
||||
|
||||
const oldFileForPatch2 = newFile;
|
||||
newFile =
|
||||
newFile.slice(0, useMemoLocation.startIndex) +
|
||||
useMemoCode +
|
||||
newFile.slice(useMemoLocation.endIndex);
|
||||
|
||||
showDiff(
|
||||
oldFileForPatch2,
|
||||
newFile,
|
||||
useMemoCode,
|
||||
useMemoLocation.startIndex,
|
||||
useMemoLocation.endIndex
|
||||
);
|
||||
|
||||
// === PATCH 3: Add range pushes for each highlighter ===
|
||||
// Re-find the match pushes location since we modified the file
|
||||
const matchPushes2 = findStartOfMatchPushes(newFile);
|
||||
if (!matchPushes2) {
|
||||
console.error(
|
||||
'^ patch: inputPatternHighlighters: matchPushes2 returned null'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const rangePushesLocation = findRangePushesInsertLocation(
|
||||
newFile,
|
||||
matchPushes2.index,
|
||||
matchPushes2.rangesVar
|
||||
);
|
||||
if (!rangePushesLocation) {
|
||||
console.error(
|
||||
'^ patch: inputPatternHighlighters: rangePushesLocation returned null'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const rangesVar = rangePushesLocation.identifiers![0];
|
||||
|
||||
// Build range push code for each highlighter
|
||||
let rangePushCode = '';
|
||||
for (let i = 0; i < enabledHighlighters.length; i++) {
|
||||
const highlighter = enabledHighlighters[i];
|
||||
const chalkChain = buildChalkChain(chalkVar, highlighter);
|
||||
const formatStr = JSON.stringify(highlighter.format);
|
||||
|
||||
rangePushCode += `for(let matchedTweakccReplacement of matchedTweakccReplacements${i}){${rangesVar}.push({start:matchedTweakccReplacement.start,end:matchedTweakccReplacement.end,style:{type:"custom",fn:${chalkChain},format:${formatStr}},priority:100})}`;
|
||||
}
|
||||
|
||||
const oldFileForPatch3 = newFile;
|
||||
newFile =
|
||||
newFile.slice(0, rangePushesLocation.startIndex) +
|
||||
rangePushCode +
|
||||
newFile.slice(rangePushesLocation.endIndex);
|
||||
|
||||
showDiff(
|
||||
oldFileForPatch3,
|
||||
newFile,
|
||||
rangePushCode,
|
||||
rangePushesLocation.startIndex,
|
||||
rangePushesLocation.endIndex
|
||||
);
|
||||
|
||||
return newFile;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import chalk from 'chalk';
|
||||
import { debug, verbose } from '../utils';
|
||||
import { debug, stringifyRegex, verbose } from '../utils';
|
||||
import { showDiff, PatchApplied } from './index';
|
||||
import {
|
||||
loadSystemPromptsWithRegex,
|
||||
@@ -32,14 +32,6 @@ const extractBuildTime = (content: string): string | undefined => {
|
||||
return match ? match[1] : undefined;
|
||||
};
|
||||
|
||||
const stringifyRegex = (regex: RegExp): string => {
|
||||
const str = regex.toString();
|
||||
const lastSlash = str.lastIndexOf('/');
|
||||
const pattern = JSON.stringify(str.substring(1, lastSlash));
|
||||
const flags = JSON.stringify(str.substring(lastSlash + 1));
|
||||
return `new RegExp(${pattern}, ${flags})`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply system prompt customizations to cli.js content
|
||||
* @param content - The current content of cli.js
|
||||
|
||||
@@ -117,6 +117,17 @@ export interface MiscConfig {
|
||||
suppressRateLimitOptions: boolean;
|
||||
}
|
||||
|
||||
export interface InputPatternHighlighter {
|
||||
name: string; // User-friendly name
|
||||
regex: string; // Regex pattern (stored as string)
|
||||
regexFlags: string; // Flags for the regex, must include 'g' for matchAll
|
||||
format: string; // Format string, use {MATCH} as placeholder
|
||||
styling: string[]; // ['bold', 'italic', 'underline', 'strikethrough', 'inverse']
|
||||
foregroundColor: string | null; // null = don't specify, otherwise rgb(r,g,b)
|
||||
backgroundColor: string | null; // null = don't specify, otherwise rgb(r,g,b)
|
||||
enabled: boolean; // Temporarily disable this pattern
|
||||
}
|
||||
|
||||
export interface Toolset {
|
||||
name: string;
|
||||
allowedTools: string[] | '*';
|
||||
@@ -139,6 +150,8 @@ export interface Settings {
|
||||
defaultToolset: string | null;
|
||||
planModeToolset: string | null;
|
||||
subagentModels: SubagentModelsConfig;
|
||||
inputPatternHighlighters: InputPatternHighlighter[];
|
||||
inputPatternHighlightersTestText: string; // Global test text for previewing highlighters
|
||||
}
|
||||
|
||||
export interface TweakccConfig {
|
||||
@@ -189,6 +202,7 @@ export enum MainMenuItem {
|
||||
THINKING_VERBS = 'Thinking verbs',
|
||||
THINKING_STYLE = 'Thinking style',
|
||||
USER_MESSAGE_DISPLAY = 'User message display',
|
||||
INPUT_PATTERN_HIGHLIGHTERS = 'Input pattern highlighters',
|
||||
MISC = 'Misc',
|
||||
TOOLSETS = 'Toolsets',
|
||||
SUBAGENT_MODELS = 'Subagent models',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ThemesView } from './components/ThemesView';
|
||||
import { ThinkingVerbsView } from './components/ThinkingVerbsView';
|
||||
import { ThinkingStyleView } from './components/ThinkingStyleView';
|
||||
import { UserMessageDisplayView } from './components/UserMessageDisplayView';
|
||||
import { InputPatternHighlightersView } from './components/InputPatternHighlightersView';
|
||||
import { MiscView } from './components/MiscView';
|
||||
import { ToolsetsView } from './components/ToolsetsView';
|
||||
import { SubagentModelsView } from './components/SubagentModelsView';
|
||||
@@ -151,6 +152,7 @@ Please reapply your changes below.`,
|
||||
case MainMenuItem.THINKING_VERBS:
|
||||
case MainMenuItem.THINKING_STYLE:
|
||||
case MainMenuItem.USER_MESSAGE_DISPLAY:
|
||||
case MainMenuItem.INPUT_PATTERN_HIGHLIGHTERS:
|
||||
case MainMenuItem.MISC:
|
||||
case MainMenuItem.TOOLSETS:
|
||||
case MainMenuItem.SUBAGENT_MODELS:
|
||||
@@ -218,6 +220,8 @@ Please reapply your changes below.`,
|
||||
<ThinkingStyleView onBack={handleBack} />
|
||||
) : currentView === MainMenuItem.USER_MESSAGE_DISPLAY ? (
|
||||
<UserMessageDisplayView onBack={handleBack} />
|
||||
) : currentView === MainMenuItem.INPUT_PATTERN_HIGHLIGHTERS ? (
|
||||
<InputPatternHighlightersView onBack={handleBack} />
|
||||
) : currentView === MainMenuItem.MISC ? (
|
||||
<MiscView onSubmit={handleBack} />
|
||||
) : currentView === MainMenuItem.TOOLSETS ? (
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { SettingsContext } from '../App';
|
||||
import Header from './Header';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
import { getCurrentClaudeCodeTheme } from '@/utils';
|
||||
|
||||
interface InputPatternHighlighterEditViewProps {
|
||||
highlighterIndex: number;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const STYLING_OPTIONS = [
|
||||
{ label: 'bold', value: 'bold' },
|
||||
{ label: 'italic', value: 'italic' },
|
||||
{ label: 'underline', value: 'underline' },
|
||||
{ label: 'strikethrough', value: 'strikethrough' },
|
||||
{ label: 'inverse', value: 'inverse' },
|
||||
];
|
||||
|
||||
type FieldName =
|
||||
| 'name'
|
||||
| 'regex'
|
||||
| 'regexFlags'
|
||||
| 'format'
|
||||
| 'styling'
|
||||
| 'foreground'
|
||||
| 'background'
|
||||
| 'enabled'
|
||||
| 'testText';
|
||||
|
||||
type ColorMode = 'none' | 'custom';
|
||||
type ColorPickerType = 'foreground' | 'background';
|
||||
|
||||
// Row-based field navigation - defined outside component to avoid recreation
|
||||
const FIELD_ROWS: FieldName[][] = [
|
||||
['name', 'regex', 'regexFlags'],
|
||||
['format', 'styling'],
|
||||
['foreground', 'background'],
|
||||
['enabled'],
|
||||
['testText'],
|
||||
];
|
||||
|
||||
// Helper component to render highlighted test text
|
||||
interface HighlightedTestTextProps {
|
||||
testText: string;
|
||||
regex: string;
|
||||
regexFlags: string;
|
||||
format: string;
|
||||
styling: string[];
|
||||
foregroundColor?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
function HighlightedTestText({
|
||||
testText,
|
||||
regex,
|
||||
regexFlags,
|
||||
format,
|
||||
styling,
|
||||
foregroundColor,
|
||||
backgroundColor,
|
||||
}: HighlightedTestTextProps) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
|
||||
try {
|
||||
const flags = regexFlags.includes('g') ? regexFlags : regexFlags + 'g';
|
||||
const re = new RegExp(regex, flags);
|
||||
const matches = [...testText.matchAll(re)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
return <Text>{testText}</Text>;
|
||||
}
|
||||
|
||||
let lastIndex = 0;
|
||||
matches.forEach((match, i) => {
|
||||
const matchStart = match.index!;
|
||||
const matchEnd = matchStart + match[0].length;
|
||||
|
||||
if (matchStart > lastIndex) {
|
||||
parts.push(
|
||||
<Text key={`plain-${i}`}>
|
||||
{testText.slice(lastIndex, matchStart)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const formattedMatch = format.replace(/\{MATCH\}/g, match[0]);
|
||||
parts.push(
|
||||
<Text
|
||||
key={`match-${i}`}
|
||||
bold={styling.includes('bold')}
|
||||
italic={styling.includes('italic')}
|
||||
underline={styling.includes('underline')}
|
||||
strikethrough={styling.includes('strikethrough')}
|
||||
inverse={styling.includes('inverse')}
|
||||
color={foregroundColor}
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
{formattedMatch}
|
||||
</Text>
|
||||
);
|
||||
|
||||
lastIndex = matchEnd;
|
||||
});
|
||||
|
||||
if (lastIndex < testText.length) {
|
||||
parts.push(<Text key="plain-end">{testText.slice(lastIndex)}</Text>);
|
||||
}
|
||||
|
||||
return <>{parts}</>;
|
||||
} catch {
|
||||
return <Text>{testText}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
export function InputPatternHighlighterEditView({
|
||||
highlighterIndex,
|
||||
onBack,
|
||||
}: InputPatternHighlighterEditViewProps) {
|
||||
const { settings, updateSettings } = useContext(SettingsContext);
|
||||
const highlighter = settings.inputPatternHighlighters[highlighterIndex];
|
||||
|
||||
const currentThemeId = getCurrentClaudeCodeTheme();
|
||||
const currentTheme =
|
||||
settings.themes?.find(t => t.id === currentThemeId) || settings.themes?.[0];
|
||||
|
||||
// Form state - foreground defaults to custom with magenta
|
||||
const [name, setName] = useState(highlighter?.name || 'New Highlighter');
|
||||
const [regex, setRegex] = useState(highlighter?.regex || '');
|
||||
const [regexFlags, setRegexFlags] = useState(highlighter?.regexFlags || 'g');
|
||||
const [format, setFormat] = useState(highlighter?.format || '{MATCH}');
|
||||
const [styling, setStyling] = useState<string[]>(highlighter?.styling || []);
|
||||
const [foregroundMode, setForegroundMode] = useState<ColorMode>(
|
||||
highlighter?.foregroundColor === null ? 'none' : 'custom'
|
||||
);
|
||||
const [foregroundColor, setForegroundColor] = useState(
|
||||
highlighter?.foregroundColor || 'rgb(255,0,255)'
|
||||
);
|
||||
const [backgroundMode, setBackgroundMode] = useState<ColorMode>(
|
||||
highlighter?.backgroundColor ? 'custom' : 'none'
|
||||
);
|
||||
const [backgroundColor, setBackgroundColor] = useState(
|
||||
highlighter?.backgroundColor || 'rgb(0,0,0)'
|
||||
);
|
||||
const [enabled, setEnabled] = useState(highlighter?.enabled ?? true);
|
||||
// Global test text from settings
|
||||
const [testText, setTestText] = useState(
|
||||
settings.inputPatternHighlightersTestText ||
|
||||
'Type test text here to see highlighting'
|
||||
);
|
||||
|
||||
// UI state
|
||||
const [selectedField, setSelectedField] = useState<FieldName>('name');
|
||||
const [editingText, setEditingText] = useState(false);
|
||||
const [stylingIndex, setStylingIndex] = useState(0);
|
||||
const [colorPickerType, setColorPickerType] =
|
||||
useState<ColorPickerType | null>(null);
|
||||
const [originalColor, setOriginalColor] = useState('');
|
||||
const [regexError, setRegexError] = useState<string | null>(null);
|
||||
|
||||
// Row-based field navigation
|
||||
const [rowIndex, setRowIndex] = useState(0);
|
||||
const [colIndex, setColIndex] = useState(0);
|
||||
|
||||
// Update selectedField when row/col changes
|
||||
useEffect(() => {
|
||||
const row = FIELD_ROWS[rowIndex];
|
||||
const col = Math.min(colIndex, row.length - 1);
|
||||
setSelectedField(row[col]);
|
||||
}, [rowIndex, colIndex]);
|
||||
|
||||
// Validate regex
|
||||
useEffect(() => {
|
||||
if (!regex) {
|
||||
setRegexError(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new RegExp(regex, regexFlags);
|
||||
setRegexError(null);
|
||||
} catch (e) {
|
||||
setRegexError((e as Error).message);
|
||||
}
|
||||
}, [regex, regexFlags]);
|
||||
|
||||
// Save highlighter settings on change
|
||||
useEffect(() => {
|
||||
if (highlighterIndex >= 0) {
|
||||
updateSettings(settings => {
|
||||
if (settings.inputPatternHighlighters[highlighterIndex]) {
|
||||
settings.inputPatternHighlighters[highlighterIndex] = {
|
||||
name,
|
||||
regex,
|
||||
regexFlags,
|
||||
format,
|
||||
styling,
|
||||
foregroundColor:
|
||||
foregroundMode === 'custom' ? foregroundColor : null,
|
||||
backgroundColor:
|
||||
backgroundMode === 'custom' ? backgroundColor : null,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [
|
||||
highlighterIndex,
|
||||
name,
|
||||
regex,
|
||||
regexFlags,
|
||||
format,
|
||||
styling,
|
||||
foregroundMode,
|
||||
foregroundColor,
|
||||
backgroundMode,
|
||||
backgroundColor,
|
||||
enabled,
|
||||
]);
|
||||
|
||||
// Save global test text separately
|
||||
useEffect(() => {
|
||||
updateSettings(settings => {
|
||||
settings.inputPatternHighlightersTestText = testText;
|
||||
});
|
||||
}, [testText]);
|
||||
|
||||
useInput((input, key) => {
|
||||
// Text editing mode
|
||||
if (editingText) {
|
||||
if (key.return) {
|
||||
setEditingText(false);
|
||||
} else if (key.escape) {
|
||||
if (selectedField === 'name') setName(highlighter?.name || '');
|
||||
if (selectedField === 'regex') setRegex(highlighter?.regex || '');
|
||||
if (selectedField === 'regexFlags')
|
||||
setRegexFlags(highlighter?.regexFlags || 'g');
|
||||
if (selectedField === 'format')
|
||||
setFormat(highlighter?.format || '{MATCH}');
|
||||
if (selectedField === 'testText')
|
||||
setTestText(
|
||||
settings.inputPatternHighlightersTestText ||
|
||||
'Type test text here to see highlighting'
|
||||
);
|
||||
setEditingText(false);
|
||||
} else if (key.backspace || key.delete) {
|
||||
if (selectedField === 'name') setName(prev => prev.slice(0, -1));
|
||||
if (selectedField === 'regex') setRegex(prev => prev.slice(0, -1));
|
||||
if (selectedField === 'regexFlags')
|
||||
setRegexFlags(prev => prev.slice(0, -1));
|
||||
if (selectedField === 'format') setFormat(prev => prev.slice(0, -1));
|
||||
if (selectedField === 'testText')
|
||||
setTestText(prev => prev.slice(0, -1));
|
||||
} else if (input && input.length === 1) {
|
||||
if (selectedField === 'name') setName(prev => prev + input);
|
||||
if (selectedField === 'regex') setRegex(prev => prev + input);
|
||||
if (selectedField === 'regexFlags') setRegexFlags(prev => prev + input);
|
||||
if (selectedField === 'format') setFormat(prev => prev + input);
|
||||
if (selectedField === 'testText') setTestText(prev => prev + input);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (colorPickerType !== null) return;
|
||||
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
} else if (key.upArrow) {
|
||||
if (selectedField === 'styling') {
|
||||
if (stylingIndex > 0) {
|
||||
setStylingIndex(prev => prev - 1);
|
||||
} else {
|
||||
setRowIndex(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
} else {
|
||||
setRowIndex(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
} else if (key.downArrow) {
|
||||
if (selectedField === 'styling') {
|
||||
if (stylingIndex < STYLING_OPTIONS.length - 1) {
|
||||
setStylingIndex(prev => prev + 1);
|
||||
} else {
|
||||
setRowIndex(prev => Math.min(FIELD_ROWS.length - 1, prev + 1));
|
||||
}
|
||||
} else {
|
||||
setRowIndex(prev => Math.min(FIELD_ROWS.length - 1, prev + 1));
|
||||
}
|
||||
} else if (key.leftArrow) {
|
||||
if (selectedField === 'foreground') {
|
||||
setForegroundMode(prev => (prev === 'none' ? 'custom' : 'none'));
|
||||
} else if (selectedField === 'background') {
|
||||
setBackgroundMode(prev => (prev === 'none' ? 'custom' : 'none'));
|
||||
} else {
|
||||
setColIndex(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
} else if (key.rightArrow) {
|
||||
if (selectedField === 'foreground') {
|
||||
setForegroundMode(prev => (prev === 'none' ? 'custom' : 'none'));
|
||||
} else if (selectedField === 'background') {
|
||||
setBackgroundMode(prev => (prev === 'none' ? 'custom' : 'none'));
|
||||
} else {
|
||||
const maxCol = FIELD_ROWS[rowIndex].length - 1;
|
||||
setColIndex(prev => Math.min(maxCol, prev + 1));
|
||||
}
|
||||
} else if (key.tab) {
|
||||
// Tab moves through all fields linearly
|
||||
const allFields = FIELD_ROWS.flat();
|
||||
const currentIdx = allFields.indexOf(selectedField);
|
||||
if (key.shift) {
|
||||
if (currentIdx > 0) {
|
||||
const newField = allFields[currentIdx - 1];
|
||||
for (let r = 0; r < FIELD_ROWS.length; r++) {
|
||||
const c = FIELD_ROWS[r].indexOf(newField);
|
||||
if (c >= 0) {
|
||||
setRowIndex(r);
|
||||
setColIndex(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (currentIdx < allFields.length - 1) {
|
||||
const newField = allFields[currentIdx + 1];
|
||||
for (let r = 0; r < FIELD_ROWS.length; r++) {
|
||||
const c = FIELD_ROWS[r].indexOf(newField);
|
||||
if (c >= 0) {
|
||||
setRowIndex(r);
|
||||
setColIndex(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (key.return) {
|
||||
if (
|
||||
selectedField === 'name' ||
|
||||
selectedField === 'regex' ||
|
||||
selectedField === 'regexFlags' ||
|
||||
selectedField === 'format' ||
|
||||
selectedField === 'testText'
|
||||
) {
|
||||
setEditingText(true);
|
||||
} else if (
|
||||
selectedField === 'foreground' &&
|
||||
foregroundMode === 'custom'
|
||||
) {
|
||||
setOriginalColor(foregroundColor);
|
||||
setColorPickerType('foreground');
|
||||
} else if (
|
||||
selectedField === 'background' &&
|
||||
backgroundMode === 'custom'
|
||||
) {
|
||||
setOriginalColor(backgroundColor);
|
||||
setColorPickerType('background');
|
||||
}
|
||||
} else if (input === ' ') {
|
||||
if (selectedField === 'styling') {
|
||||
const option = STYLING_OPTIONS[stylingIndex].value;
|
||||
if (styling.includes(option)) {
|
||||
setStyling(styling.filter(s => s !== option));
|
||||
} else {
|
||||
setStyling([...styling, option]);
|
||||
}
|
||||
} else if (selectedField === 'enabled') {
|
||||
setEnabled(!enabled);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!highlighter) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="red">Highlighter not found</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (colorPickerType) {
|
||||
return (
|
||||
<ColorPicker
|
||||
initialValue={originalColor}
|
||||
theme={currentTheme}
|
||||
onColorChange={color => {
|
||||
if (colorPickerType === 'foreground') {
|
||||
setForegroundColor(color);
|
||||
} else if (colorPickerType === 'background') {
|
||||
setBackgroundColor(color);
|
||||
}
|
||||
}}
|
||||
onExit={() => {
|
||||
setColorPickerType(null);
|
||||
setOriginalColor('');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isFieldSelected = (field: FieldName) => selectedField === field;
|
||||
const fieldStyle = (field: FieldName) => ({
|
||||
color: isFieldSelected(field) ? 'yellow' : undefined,
|
||||
bold: isFieldSelected(field),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Header>Edit Highlighter</Header>
|
||||
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text dimColor>
|
||||
arrows to navigate · enter to edit · space to toggle · esc to go back
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Row 1: Name, Regex Pattern, Regex Flags */}
|
||||
<Box flexDirection="row" gap={2} marginBottom={1}>
|
||||
<Box flexDirection="column" width="30%">
|
||||
<Text {...fieldStyle('name')}>
|
||||
{isFieldSelected('name') ? '❯ ' : ' '}Name
|
||||
</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
isFieldSelected('name')
|
||||
? editingText
|
||||
? 'green'
|
||||
: 'yellow'
|
||||
: 'gray'
|
||||
}
|
||||
>
|
||||
<Text>{name || '(empty)'}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" width="45%">
|
||||
<Text {...fieldStyle('regex')}>
|
||||
{isFieldSelected('regex') ? '❯ ' : ' '}Regex Pattern
|
||||
</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
isFieldSelected('regex')
|
||||
? editingText
|
||||
? 'green'
|
||||
: 'yellow'
|
||||
: regexError
|
||||
? 'red'
|
||||
: 'gray'
|
||||
}
|
||||
>
|
||||
<Text>{regex || '(empty)'}</Text>
|
||||
</Box>
|
||||
{regexError && <Text color="red">{regexError}</Text>}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" width="20%">
|
||||
<Text {...fieldStyle('regexFlags')}>
|
||||
{isFieldSelected('regexFlags') ? '❯ ' : ' '}Flags
|
||||
</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
isFieldSelected('regexFlags')
|
||||
? editingText
|
||||
? 'green'
|
||||
: 'yellow'
|
||||
: 'gray'
|
||||
}
|
||||
>
|
||||
<Text>{regexFlags || 'g'}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Row 2: Format String, Styling */}
|
||||
<Box flexDirection="row" gap={2} marginBottom={1}>
|
||||
<Box flexDirection="column" width="50%">
|
||||
<Text {...fieldStyle('format')}>
|
||||
{isFieldSelected('format') ? '❯ ' : ' '}Format String
|
||||
</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
isFieldSelected('format')
|
||||
? editingText
|
||||
? 'green'
|
||||
: 'yellow'
|
||||
: 'gray'
|
||||
}
|
||||
>
|
||||
<Text>{format || '{MATCH}'}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{isFieldSelected('format') && (
|
||||
<Box marginLeft={2}>
|
||||
<Text dimColor>use {'{MATCH}'} as placeholder</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" width="45%">
|
||||
<Text {...fieldStyle('styling')}>
|
||||
{isFieldSelected('styling') ? '❯ ' : ' '}Styling
|
||||
</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{STYLING_OPTIONS.map((option, index) => {
|
||||
const isActive = styling.includes(option.value);
|
||||
const isHighlighted =
|
||||
isFieldSelected('styling') && stylingIndex === index;
|
||||
return (
|
||||
<Text
|
||||
key={option.value}
|
||||
color={isHighlighted ? 'cyan' : undefined}
|
||||
>
|
||||
{isHighlighted ? '❯ ' : ' '}
|
||||
{isActive ? '●' : '○'} {option.label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Row 3: Foreground Color, Background Color */}
|
||||
<Box flexDirection="row" gap={2} marginBottom={1}>
|
||||
<Box flexDirection="column" width="48%">
|
||||
<Text {...fieldStyle('foreground')}>
|
||||
{isFieldSelected('foreground') ? '❯ ' : ' '}Foreground Color
|
||||
</Text>
|
||||
<Box marginLeft={2} flexDirection="row" gap={1}>
|
||||
<Text color={isFieldSelected('foreground') ? 'yellow' : undefined}>
|
||||
[{foregroundMode === 'none' ? '●' : '○'}] none
|
||||
{' '}[{foregroundMode === 'custom' ? '●' : '○'}] custom
|
||||
</Text>
|
||||
{foregroundMode === 'custom' && (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={isFieldSelected('foreground') ? 'yellow' : 'gray'}
|
||||
>
|
||||
<Text color={foregroundColor}> ████ </Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" width="48%">
|
||||
<Text {...fieldStyle('background')}>
|
||||
{isFieldSelected('background') ? '❯ ' : ' '}Background Color
|
||||
</Text>
|
||||
<Box marginLeft={2} flexDirection="row" gap={1}>
|
||||
<Text color={isFieldSelected('background') ? 'yellow' : undefined}>
|
||||
[{backgroundMode === 'none' ? '●' : '○'}] none
|
||||
{' '}[{backgroundMode === 'custom' ? '●' : '○'}] custom
|
||||
</Text>
|
||||
{backgroundMode === 'custom' && (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={isFieldSelected('background') ? 'yellow' : 'gray'}
|
||||
>
|
||||
<Text backgroundColor={backgroundColor}> {' '} </Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Row 4: Enabled */}
|
||||
<Box marginBottom={1}>
|
||||
<Text {...fieldStyle('enabled')}>
|
||||
{isFieldSelected('enabled') ? '❯ ' : ' '}Enabled:{' '}
|
||||
<Text color={enabled ? 'green' : 'red'}>
|
||||
{enabled ? '● Yes' : '○ No'}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Row 5: Test Text */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text {...fieldStyle('testText')}>
|
||||
{isFieldSelected('testText') ? '❯ ' : ' '}Test Text
|
||||
</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
isFieldSelected('testText')
|
||||
? editingText
|
||||
? 'green'
|
||||
: 'yellow'
|
||||
: 'gray'
|
||||
}
|
||||
>
|
||||
<Text>{testText || '(empty)'}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Live Preview */}
|
||||
<Box borderStyle="round" padding={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Live Preview:</Text>
|
||||
{regex ? (
|
||||
<Box marginTop={1}>
|
||||
<HighlightedTestText
|
||||
testText={testText}
|
||||
regex={regex}
|
||||
regexFlags={regexFlags}
|
||||
format={format}
|
||||
styling={styling}
|
||||
foregroundColor={
|
||||
foregroundMode === 'custom' ? foregroundColor : undefined
|
||||
}
|
||||
backgroundColor={
|
||||
backgroundMode === 'custom' ? backgroundColor : undefined
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Text dimColor>Enter a regex pattern to see the preview</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useState, useContext } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
|
||||
import { InputPatternHighlighter } from '@/types';
|
||||
import { getCurrentClaudeCodeTheme } from '@/utils';
|
||||
import { DEFAULT_SETTINGS } from '@/defaultSettings';
|
||||
|
||||
import { InputPatternHighlighterEditView } from './InputPatternHighlighterEditView';
|
||||
import Header from './Header';
|
||||
import { SettingsContext } from '../App';
|
||||
|
||||
interface InputPatternHighlightersViewProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function InputPatternHighlightersView({
|
||||
onBack,
|
||||
}: InputPatternHighlightersViewProps) {
|
||||
const {
|
||||
settings: { inputPatternHighlighters, themes },
|
||||
updateSettings,
|
||||
} = useContext(SettingsContext);
|
||||
|
||||
// Get current theme colors
|
||||
const currentThemeId = getCurrentClaudeCodeTheme();
|
||||
const currentTheme = themes.find(t => t.id === currentThemeId) || themes[0];
|
||||
|
||||
const defaultTheme = DEFAULT_SETTINGS.themes[0]; // Dark mode theme
|
||||
const successColor =
|
||||
currentTheme?.colors.success || defaultTheme.colors.success;
|
||||
const errorColor = currentTheme?.colors.error || defaultTheme.colors.error;
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [editingHighlighterIndex, setEditingHighlighterIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [inputActive, setInputActive] = useState(true);
|
||||
|
||||
const handleCreateHighlighter = () => {
|
||||
const newHighlighter: InputPatternHighlighter = {
|
||||
name: 'New Highlighter',
|
||||
regex: '',
|
||||
regexFlags: 'g',
|
||||
format: '{MATCH}',
|
||||
styling: [],
|
||||
foregroundColor: 'rgb(255,0,255)', // Default to custom magenta
|
||||
backgroundColor: null,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
updateSettings(settings => {
|
||||
settings.inputPatternHighlighters.push(newHighlighter);
|
||||
});
|
||||
|
||||
setEditingHighlighterIndex(inputPatternHighlighters.length);
|
||||
setInputActive(false);
|
||||
};
|
||||
|
||||
const handleDeleteHighlighter = (index: number) => {
|
||||
updateSettings(settings => {
|
||||
settings.inputPatternHighlighters.splice(index, 1);
|
||||
});
|
||||
|
||||
if (selectedIndex >= inputPatternHighlighters.length - 1) {
|
||||
setSelectedIndex(Math.max(0, inputPatternHighlighters.length - 2));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = (index: number) => {
|
||||
updateSettings(settings => {
|
||||
settings.inputPatternHighlighters[index].enabled =
|
||||
!settings.inputPatternHighlighters[index].enabled;
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index <= 0) return;
|
||||
updateSettings(settings => {
|
||||
const item = settings.inputPatternHighlighters[index];
|
||||
settings.inputPatternHighlighters.splice(index, 1);
|
||||
settings.inputPatternHighlighters.splice(index - 1, 0, item);
|
||||
});
|
||||
setSelectedIndex(index - 1);
|
||||
};
|
||||
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (index >= inputPatternHighlighters.length - 1) return;
|
||||
updateSettings(settings => {
|
||||
const item = settings.inputPatternHighlighters[index];
|
||||
settings.inputPatternHighlighters.splice(index, 1);
|
||||
settings.inputPatternHighlighters.splice(index + 1, 0, item);
|
||||
});
|
||||
setSelectedIndex(index + 1);
|
||||
};
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
onBack();
|
||||
} else if (key.upArrow) {
|
||||
setSelectedIndex(prev => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow && inputPatternHighlighters.length > 0) {
|
||||
setSelectedIndex(prev =>
|
||||
Math.min(inputPatternHighlighters.length - 1, prev + 1)
|
||||
);
|
||||
} else if (key.return && inputPatternHighlighters.length > 0) {
|
||||
setEditingHighlighterIndex(selectedIndex);
|
||||
setInputActive(false);
|
||||
} else if (input === 'n') {
|
||||
handleCreateHighlighter();
|
||||
} else if (input === 'x' && inputPatternHighlighters.length > 0) {
|
||||
handleDeleteHighlighter(selectedIndex);
|
||||
} else if (input === ' ' && inputPatternHighlighters.length > 0) {
|
||||
handleToggleEnabled(selectedIndex);
|
||||
} else if (
|
||||
input === 'u' &&
|
||||
inputPatternHighlighters.length > 0 &&
|
||||
selectedIndex > 0
|
||||
) {
|
||||
handleMoveUp(selectedIndex);
|
||||
} else if (
|
||||
input === 'd' &&
|
||||
inputPatternHighlighters.length > 0 &&
|
||||
selectedIndex < inputPatternHighlighters.length - 1
|
||||
) {
|
||||
handleMoveDown(selectedIndex);
|
||||
}
|
||||
},
|
||||
{ isActive: inputActive }
|
||||
);
|
||||
|
||||
// Handle editing highlighter view
|
||||
if (editingHighlighterIndex !== null) {
|
||||
return (
|
||||
<InputPatternHighlighterEditView
|
||||
highlighterIndex={editingHighlighterIndex}
|
||||
onBack={() => {
|
||||
setEditingHighlighterIndex(null);
|
||||
setInputActive(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Header>Input Pattern Highlighters</Header>
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text dimColor>
|
||||
Create custom highlighters for patterns in your input prompt.
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Matched text will be styled and optionally reformatted.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text dimColor>n to create a new highlighter</Text>
|
||||
{inputPatternHighlighters.length > 0 && (
|
||||
<Text dimColor>space to toggle enabled/disabled</Text>
|
||||
)}
|
||||
{inputPatternHighlighters.length > 0 && (
|
||||
<Text dimColor>u/d to move highlighter up/down</Text>
|
||||
)}
|
||||
{inputPatternHighlighters.length > 0 && (
|
||||
<Text dimColor>x to delete a highlighter</Text>
|
||||
)}
|
||||
{inputPatternHighlighters.length > 0 && (
|
||||
<Text dimColor>enter to edit highlighter</Text>
|
||||
)}
|
||||
<Text dimColor>esc to go back</Text>
|
||||
</Box>
|
||||
|
||||
{inputPatternHighlighters.length === 0 ? (
|
||||
<Text>No highlighters created yet. Press n to create one.</Text>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{inputPatternHighlighters.map((highlighter, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
|
||||
// Determine the color for the entire line
|
||||
let lineColor: string | undefined = undefined;
|
||||
if (isSelected) {
|
||||
lineColor = 'yellow';
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={index} flexDirection="row">
|
||||
<Text color={lineColor}>
|
||||
{isSelected ? '❯ ' : ' '}
|
||||
{highlighter.enabled ? '● ' : '○ '}
|
||||
{highlighter.name}{' '}
|
||||
</Text>
|
||||
|
||||
<Text dimColor>(</Text>
|
||||
|
||||
{highlighter.regex ? (
|
||||
<Text dimColor>
|
||||
/{highlighter.regex}/{highlighter.regexFlags}
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>(no regex)</Text>
|
||||
)}
|
||||
|
||||
{highlighter.styling.length > 0 && (
|
||||
<>
|
||||
<Text color={lineColor} dimColor={!highlighter.enabled}>
|
||||
{' '}
|
||||
· {highlighter.styling.join(', ')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text dimColor>)</Text>
|
||||
|
||||
{highlighter.enabled ? (
|
||||
<Text color={successColor}> enabled</Text>
|
||||
) : (
|
||||
<Text color={errorColor}> disabled</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,10 @@ const baseMenuItems: SelectItem[] = [
|
||||
name: MainMenuItem.USER_MESSAGE_DISPLAY,
|
||||
desc: 'Customize how user messages are displayed',
|
||||
},
|
||||
{
|
||||
name: MainMenuItem.INPUT_PATTERN_HIGHLIGHTERS,
|
||||
desc: 'Create custom highlighters for patterns in your input',
|
||||
},
|
||||
{
|
||||
name: MainMenuItem.MISC,
|
||||
desc: 'Miscellaneous settings (input box border, etc.)',
|
||||
|
||||
@@ -398,3 +398,11 @@ export const compareSemverVersions = (a: string, b: string): number => {
|
||||
if (aParts[1] !== bParts[1]) return aParts[1] - bParts[1];
|
||||
return aParts[2] - bParts[2];
|
||||
};
|
||||
|
||||
export const stringifyRegex = (regex: RegExp): string => {
|
||||
const str = regex.toString();
|
||||
const lastSlash = str.lastIndexOf('/');
|
||||
const pattern = JSON.stringify(str.substring(1, lastSlash));
|
||||
const flags = JSON.stringify(str.substring(lastSlash + 1));
|
||||
return `new RegExp(${pattern}, ${flags})`;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user