diff options
| author | Paul Buetow <paul@buetow.org> | 2025-10-03 23:50:49 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-10-03 23:50:49 +0300 |
| commit | 420b5aebf888c638ac096e1476c06eac979ac257 (patch) | |
| tree | 59434cbc37837399d7b5bc7920ffd7be62f1fc7d | |
| parent | e36a5446bc62842ae3b3e165f66fecb7285a8c6a (diff) | |
Switch inline prompt markers to >! prefixv0.15.1
30 files changed, 295 insertions, 163 deletions
diff --git a/config.toml.example b/config.toml.example index 9aa217f..cd10e73 100644 --- a/config.toml.example +++ b/config.toml.example @@ -20,7 +20,7 @@ manual_invoke_min_prefix = 0 # required identifier chars for manual invo trigger_characters = [".", ":", "/", "_", " "] [inline] -inline_open = ">" # single-character +inline_open = ">!" # marker prefix for inline prompts inline_close = ">" # single-character [chat] diff --git a/docs/usage.md b/docs/usage.md index 1dadb1c..49ed4e6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -33,7 +33,7 @@ Note: additional LSPs (`gopls`, `golangci-lint-lsp`) are optional; Hexai works w Ask a question at the end of a line and receive the answer inline. - End your question line with a trigger: `?>`, `!>`, or `:>`. -- Hexai removes only the trailing `>` from the question line (and keeps your trailing punctuation). Inline code-completion triggers now use `>text>` (inline) or `>>text>` (line-replace). +- Hexai removes only the trailing `>` from the question line (and keeps your trailing punctuation). Inline code-completion triggers now use `>!text>` (inline) or `>>!text>` (line-replace). - It inserts a blank line, then a reply line prefixed with `> `, then one extra newline so most editors place the cursor on a fresh blank line after the answer. - If a `>` reply already exists below the question, Hexai won’t answer again. @@ -52,10 +52,10 @@ Context: Hexai includes up to the three most recent Q/A pairs above the question ## Inline triggers -Hexai supports inline prompt tags you can type in code to request an action from the LLM and then auto-clean the tag. The new `>`-based forms are: +Hexai supports inline prompt tags you can type in code to request an action from the LLM and then auto-clean the tag. The new `>!`-based forms are: -- `>do something>` — uses the text between `>` markers as the instruction and removes only the prompt. Strict form requires no space after the first `>` and no space before the closing `>`. -- `>>do something>` — same as above, but replaces the entire current line with the completion. +- `>!do something>` — uses the text between markers as the instruction and removes only the prompt. Strict form requires no space after `>!` and no space before the closing `>`. +- `>>!do something>` — same as above, but replaces the entire current line with the completion. Spaced variants (e.g., `> spaced >`) are ignored. diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index e5a8d5f..59ffd89 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -44,7 +44,7 @@ type App struct { TriggerCharacters []string `json:"trigger_characters" toml:"trigger_characters"` Provider string `json:"provider" toml:"provider"` - // Inline prompt trigger characters (default: >text> and >>text>) + // Inline prompt trigger characters (default: >!text> and >>!text>) InlineOpen string `json:"inline_open" toml:"inline_open"` InlineClose string `json:"inline_close" toml:"inline_close"` // In-editor chat triggers (default: suffix ">" after one of [?, !, :, ;]) @@ -141,7 +141,7 @@ func newDefaultConfig() App { CompletionDebounceMs: 800, CompletionThrottleMs: 0, // Inline/chat trigger defaults - InlineOpen: ">", + InlineOpen: ">!", InlineClose: ">", ChatSuffix: ">", ChatPrefixes: []string{"?", "!", ":", ";"}, diff --git a/internal/appconfig/config_alias_test.go b/internal/appconfig/config_alias_test.go index 6cc5bda..da7909e 100644 --- a/internal/appconfig/config_alias_test.go +++ b/internal/appconfig/config_alias_test.go @@ -1,20 +1,20 @@ package appconfig import ( - "log" - "os" - "path/filepath" - "testing" + "log" + "os" + "path/filepath" + "testing" ) func TestOpenAIPresets_AliasResolution(t *testing.T) { - dir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", dir) - cfgDir := filepath.Join(dir, "hexai") - if err := os.MkdirAll(cfgDir, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - toml := ` + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfgDir := filepath.Join(dir, "hexai") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + toml := ` [provider] name = "openai" @@ -24,13 +24,12 @@ model = "codex" [openai.presets] codex = "gpt-5-codex" ` - path := filepath.Join(cfgDir, "config.toml") - if err := os.WriteFile(path, []byte(toml), 0o644); err != nil { - t.Fatalf("write: %v", err) - } - cfg := Load(log.New(os.Stderr, "test ", 0)) - if cfg.OpenAIModel != "gpt-5-codex" { - t.Fatalf("expected alias to resolve to gpt-5-codex, got %q", cfg.OpenAIModel) - } + path := filepath.Join(cfgDir, "config.toml") + if err := os.WriteFile(path, []byte(toml), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + cfg := Load(log.New(os.Stderr, "test ", 0)) + if cfg.OpenAIModel != "gpt-5-codex" { + t.Fatalf("expected alias to resolve to gpt-5-codex, got %q", cfg.OpenAIModel) + } } - diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go index 4ae04d8..2c00f68 100644 --- a/internal/appconfig/config_test.go +++ b/internal/appconfig/config_test.go @@ -365,7 +365,7 @@ manual_invoke_min_prefix = 3 trigger_characters = [".", ":"] [inline] -inline_open = ">" +inline_open = ">!" inline_close = ">" [chat] diff --git a/internal/hexaicli/run_test.go b/internal/hexaicli/run_test.go index dfde068..991965e 100644 --- a/internal/hexaicli/run_test.go +++ b/internal/hexaicli/run_test.go @@ -125,12 +125,20 @@ func TestRunWithClient_ErrorPrint(t *testing.T) { func TestRun_OpenAI_NoKey_ShowsError(t *testing.T) { dir := testingTempDir(t) - // write config with provider=openai - writeTOML(t, filepath.Join(dir, "hexai", "config.toml"), map[string]string{"provider": "openai", "openai_model": "gpt-x"}) + // write config with provider=openai using sectioned tables + configPath := filepath.Join(dir, "hexai", "config.toml") + writeConfigString(t, configPath, ` +[provider] +name = "openai" + +[openai] +model = "gpt-x" +`) t.Setenv("XDG_CONFIG_HOME", dir) // Ensure no OpenAI API key is present in environment t.Setenv("HEXAI_OPENAI_API_KEY", "") t.Setenv("OPENAI_API_KEY", "") + t.Setenv("HEXAI_PROVIDER", "") var out, errb bytes.Buffer // Run expects parsed flags; here args irrelevant err := Run(context.Background(), []string{"hello"}, strings.NewReader(""), &out, &errb) diff --git a/internal/hexaicli/testhelpers_test.go b/internal/hexaicli/testhelpers_test.go index 93f1e3d..4cc04f7 100644 --- a/internal/hexaicli/testhelpers_test.go +++ b/internal/hexaicli/testhelpers_test.go @@ -79,4 +79,14 @@ func writeTOML(t *testing.T, path string, m map[string]string) { } } +func writeConfigString(t *testing.T, path string, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatalf("write: %v", err) + } +} + func testingTempDir(t *testing.T) string { t.Helper(); return t.TempDir() } diff --git a/internal/lsp/chat_commands_test.go b/internal/lsp/chat_commands_test.go index 0e31c7b..ffe31dd 100644 --- a/internal/lsp/chat_commands_test.go +++ b/internal/lsp/chat_commands_test.go @@ -50,6 +50,7 @@ func TestHandleReloadCommandReloadsStore(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmp) t.Setenv("HEXAI_MAX_TOKENS", "321") + t.Setenv("HEXAI_PROVIDER", "") var logBuf bytes.Buffer logger := log.New(&logBuf, "", 0) @@ -96,6 +97,7 @@ func TestDetectAndHandleChatExecutesSlashCommand(t *testing.T) { } t.Setenv("XDG_CONFIG_HOME", tmp) t.Setenv("HEXAI_MAX_TOKENS", "") + t.Setenv("HEXAI_PROVIDER", "") var logBuf bytes.Buffer logger := log.New(&logBuf, "", 0) diff --git a/internal/lsp/codeaction_custom_test.go b/internal/lsp/codeaction_custom_test.go index ea8ae82..36f99d4 100644 --- a/internal/lsp/codeaction_custom_test.go +++ b/internal/lsp/codeaction_custom_test.go @@ -30,7 +30,7 @@ func capResp(t *testing.T, buf *bytes.Buffer) Response { func TestHandleCodeAction_ListsCustomActions(t *testing.T) { var out bytes.Buffer cfg := appconfig.App{ - InlineOpen: ">", + InlineOpen: ">!", InlineClose: ">", ChatSuffix: ">", ChatPrefixes: []string{"?", "!", ":", ";"}, diff --git a/internal/lsp/codeaction_test.go b/internal/lsp/codeaction_test.go index 29cb416..af08fe1 100644 --- a/internal/lsp/codeaction_test.go +++ b/internal/lsp/codeaction_test.go @@ -23,7 +23,7 @@ func TestBuildRewriteCodeAction_LazyAndResolves(t *testing.T) { s := newTestServer() s.llmClient = fakeLLM{resp: "REWRITTEN"} p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Range: Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 3, Character: 4}}} - sel := ">rewrite>\nold code" + sel := ">!rewrite>\nold code" ca := s.buildRewriteCodeAction(p, sel) if ca == nil { t.Fatalf("expected code action") diff --git a/internal/lsp/completion_prefix_strip_test.go b/internal/lsp/completion_prefix_strip_test.go index e0c655c..c8e2bd7 100644 --- a/internal/lsp/completion_prefix_strip_test.go +++ b/internal/lsp/completion_prefix_strip_test.go @@ -69,12 +69,12 @@ func TestTryLLMCompletion_InlinePromptAlwaysTriggers(t *testing.T) { cfg.TriggerCharacters = []string{".", ":", "/", "_"} s.cfg = cfg s.llmClient = fakeLLM{resp: "replacement"} - line := "prefix >do something> suffix" + line := "prefix >!do something> suffix" // No trigger char immediately before cursor; place cursor at end p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://inline.go"}} items, ok, _ := s.tryLLMCompletion(p, "", line, "", "", "", false, "") if !ok || len(items) == 0 { - t.Fatalf("expected completion to trigger on inline >text> prompt") + t.Fatalf("expected completion to trigger on inline >!text> prompt") } } @@ -87,7 +87,7 @@ func TestTryLLMCompletion_DoubleOpenEmpty_DoesNotAutoTrigger(t *testing.T) { s.cfg = cfg fake := &countingLLM{} s.llmClient = fake - line := ">> " // empty content after double-open should not force-trigger + line := ">>! " // empty content after double-open should not force-trigger p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://empty-inline.go"}} items, ok, _ := s.tryLLMCompletion(p, "", line, "", "", "", false, "") if !ok { @@ -102,16 +102,16 @@ func TestTryLLMCompletion_DoubleOpenEmpty_DoesNotAutoTrigger(t *testing.T) { } func TestHasDoubleSemicolonTrigger_Variants(t *testing.T) { - if hasDoubleOpenTrigger(">>", '>', '>') { + if hasDoubleOpenTrigger(">>!", ">!", '>', '>') { t.Fatalf("bare double-open should not trigger") } - if hasDoubleOpenTrigger(">> ", '>', '>') { + if hasDoubleOpenTrigger(">>! ", ">!", '>', '>') { t.Fatalf("double-open followed by space should not trigger") } - if hasDoubleOpenTrigger(">>>", '>', '>') { + if hasDoubleOpenTrigger(">>!>", ">!", '>', '>') { t.Fatalf("';;;' should not trigger (no content)") } - if !hasDoubleOpenTrigger(">>x>", '>', '>') { + if !hasDoubleOpenTrigger(">>!x>", ">!", '>', '>') { t.Fatalf("expected trigger for ';;x;' pattern") } } @@ -126,7 +126,7 @@ func TestBareDoubleOpenPreventsAutoTriggerEvenWithOtherTriggers(t *testing.T) { fake := &countingLLM{} s.llmClient = fake // Place a '.' earlier but also include bare double-open at end; should not auto-trigger - line := "obj. call >>" + line := "obj. call >>!" p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://bare-ds.go"}} items, ok, _ := s.tryLLMCompletion(p, "", line, "", "", "", false, "") if !ok { @@ -150,7 +150,7 @@ func TestBareDoubleOpenOnNextLine_PreventsAutoTrigger(t *testing.T) { fake := &countingLLM{} s.llmClient = fake current := "expression := flag.String(\"expression\", \"\", \"Expression to evaluate\")" - below := ">>" + below := ">>!" p := CompletionParams{Position: Position{Line: 0, Character: len(current)}, TextDocument: TextDocumentIdentifier{URI: "file://nextline.go"}} items, ok, _ := s.tryLLMCompletion(p, "", current, below, "", "", false, "") if !ok { @@ -173,7 +173,7 @@ func TestBareDoubleOpenPreventsManualInvoke(t *testing.T) { s.cfg = cfg fake := &countingLLM{} s.llmClient = fake - line := ">>" + line := ">>!" p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://bare-ds-manual.go"}} // Simulate manual invoke p.Context = json.RawMessage([]byte(`{"triggerKind":1}`)) diff --git a/internal/lsp/coverage_add_test.go b/internal/lsp/coverage_add_test.go index b3b7322..2967fb5 100644 --- a/internal/lsp/coverage_add_test.go +++ b/internal/lsp/coverage_add_test.go @@ -56,7 +56,7 @@ func TestFindGoFunctionAtLine_NoBody(t *testing.T) { } func TestLineHasInlinePrompt(t *testing.T) { - if !lineHasInlinePrompt(">do>", '>', '>') { + if !lineHasInlinePrompt(">!do>", ">!", '>', '>') { t.Fatalf("expected inline prompt") } } @@ -89,12 +89,12 @@ func TestIndentHelpersAndPromptRemoval(t *testing.T) { t.Fatalf("applyIndent: %q", out) } // double-open trigger removes whole line - edits := promptRemovalEditsForLine(">>ask>", 3, '>', '>') + edits := promptRemovalEditsForLine(">>!ask>", 3, ">!", '>', '>') if len(edits) != 1 || edits[0].Range.Start.Line != 3 { t.Fatalf("unexpected edits: %#v", edits) } // semicolon tags collect correctly when provided explicitly - edits2 := collectSemicolonMarkers("pre;do;post", 1, ';', ';') + edits2 := collectSemicolonMarkers("pre;do;post", 1, ";", ';', ';') if len(edits2) != 1 { t.Fatalf("expected one semicolon edit, got %#v", edits2) } diff --git a/internal/lsp/document_test.go b/internal/lsp/document_test.go index fd13e5d..95f0157 100644 --- a/internal/lsp/document_test.go +++ b/internal/lsp/document_test.go @@ -13,7 +13,7 @@ import ( func newTestServer() *Server { cfg := appconfig.App{ - InlineOpen: ">", + InlineOpen: ">!", InlineClose: ">", ChatSuffix: ">", ChatPrefixes: []string{"?", "!", ":", ";"}, @@ -47,7 +47,7 @@ func newTestServer() *Server { func initServerDefaults(s *Server) { cfg := s.cfg if strings.TrimSpace(cfg.InlineOpen) == "" { - cfg.InlineOpen = ">" + cfg.InlineOpen = ">!" } if strings.TrimSpace(cfg.InlineClose) == "" { cfg.InlineClose = ">" diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index 94b6348..7b61970 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -51,8 +51,8 @@ func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned text string } cands := []cand{} - _, _, openChar, closeChar := s.inlineMarkers() - if t, l, r, ok := findStrictInlineTag(line, openChar, closeChar); ok { + openStr, _, openChar, closeChar := s.inlineMarkers() + if t, l, r, ok := findStrictInlineTag(line, openStr, openChar, closeChar); ok { cands = append(cands, cand{start: l, end: r, text: t}) } if i := strings.Index(line, "/*"); i >= 0 { @@ -288,6 +288,7 @@ func (s *Server) compCacheTouchLocked(key string) { // immediately to the left of the cursor. func (s *Server) isTriggerEvent(p CompletionParams, current string) bool { open, _, openChar, closeChar := s.inlineMarkers() + doubleSeqs := doubleOpenSequences(open, openChar, closeChar) triggerChars := s.triggerCharacters() // 1) Inspect LSP completion context if present if p.Context != nil { @@ -301,9 +302,9 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool { b, _ := json.Marshal(p.Context) _ = json.Unmarshal(b, &ctx) } - // If configured and the line contains a bare double-open marker (e.g., '>>' with no '>>text>'), + // If configured and the line contains a bare double-open marker (e.g., '>>!' with no '>>!text>'), // do not treat as a trigger source. - if open != "" && strings.Contains(current, open+open) && !hasDoubleOpenTrigger(current, openChar, closeChar) { + if containsAny(current, doubleSeqs) && !hasDoubleOpenTrigger(current, open, openChar, closeChar) { return false } // TriggerKind 1 = Invoked (manual). Always allow manual invoke. @@ -331,7 +332,7 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool { return false } // Bare double-open should not trigger via fallback char either (only when configured) - if open != "" && strings.Contains(current, open+open) && !hasDoubleOpenTrigger(current, openChar, closeChar) { + if containsAny(current, doubleSeqs) && !hasDoubleOpenTrigger(current, open, openChar, closeChar) { return false } ch := string(current[idx-1]) @@ -366,6 +367,18 @@ func (s *Server) makeCompletionItems(cleaned string, inParams bool, current stri }} } +func containsAny(haystack string, seqs []string) bool { + for _, seq := range seqs { + if seq == "" { + continue + } + if strings.Contains(haystack, seq) { + return true + } + } + return false +} + // small helpers to keep tryLLMCompletion short // LLM stats helpers moved to handlers_utils.go diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go index db6866b..2fac1f3 100644 --- a/internal/lsp/handlers_completion.go +++ b/internal/lsp/handlers_completion.go @@ -199,8 +199,8 @@ func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below hasExtra: hasExtra, extraText: extraText, } - _, _, openChar, closeChar := s.inlineMarkers() - plan.inlinePrompt = lineHasInlinePrompt(current, openChar, closeChar) + openStr, _, openChar, closeChar := s.inlineMarkers() + plan.inlinePrompt = lineHasInlinePrompt(current, openStr, openChar, closeChar) if !plan.inlinePrompt && !s.isTriggerEvent(p, current) { logging.Logf("lsp ", "%scompletion skip=no-trigger line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) return plan, []CompletionItem{}, true @@ -214,7 +214,7 @@ func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below if pending := s.takePendingCompletion(plan.cacheKey); len(pending) > 0 { return plan, pending, true } - if isBareDoubleOpen(current, openChar, closeChar) || isBareDoubleOpen(below, openChar, closeChar) { + if isBareDoubleOpen(current, openStr, openChar, closeChar) || isBareDoubleOpen(below, openStr, openChar, closeChar) { logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) return plan, []CompletionItem{}, true } @@ -368,7 +368,7 @@ func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completio before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) path := strings.TrimPrefix(p.TextDocument.URI, "file://") cfg := s.currentConfig() - _, _, openChar, closeChar := s.inlineMarkers() + openStr, _, openChar, closeChar := s.inlineMarkers() prompt := renderTemplate(cfg.PromptNativeCompletion, map[string]string{ "path": path, "before": before, @@ -409,7 +409,7 @@ func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completio if cleaned == "" { return nil, false } - if strings.TrimSpace(cleaned) != "" && hasDoubleOpenTrigger(current, openChar, closeChar) { + if strings.TrimSpace(cleaned) != "" && hasDoubleOpenTrigger(current, openStr, openChar, closeChar) { indent := leadingIndent(current) if indent != "" { cleaned = applyIndent(indent, cleaned) @@ -537,8 +537,8 @@ func (s *Server) postProcessCompletion(text string, leftOfCursor string, current if cleaned != "" { cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned) } - _, _, openChar, closeChar := s.inlineMarkers() - if cleaned != "" && hasDoubleOpenTrigger(currentLine, openChar, closeChar) { + openStr, _, openChar, closeChar := s.inlineMarkers() + if cleaned != "" && hasDoubleOpenTrigger(currentLine, openStr, openChar, closeChar) { if indent := leadingIndent(currentLine); indent != "" { cleaned = applyIndent(indent, cleaned) } diff --git a/internal/lsp/handlers_document.go b/internal/lsp/handlers_document.go index da7db51..a047324 100644 --- a/internal/lsp/handlers_document.go +++ b/internal/lsp/handlers_document.go @@ -91,9 +91,9 @@ func (s *Server) detectAndHandleChat(uri string) { return } suffix, prefixes, _ := s.chatConfig() - _, _, openChar, closeChar := s.inlineMarkers() + openStr, _, openChar, closeChar := s.inlineMarkers() for i, raw := range d.lines { - if lineHasInlinePrompt(raw, openChar, closeChar) { + if lineHasInlinePrompt(raw, openStr, openChar, closeChar) { if s.currentLLMClient() != nil { pos := Position{Line: i, Character: len(raw)} go s.runInlinePrompt(uri, pos) @@ -221,8 +221,8 @@ func (s *Server) runInlinePrompt(uri string, pos Position) { return } line := d.lines[pos.Line] - _, _, openChar, closeChar := s.inlineMarkers() - if !lineHasInlinePrompt(line, openChar, closeChar) { + openStr, _, openChar, closeChar := s.inlineMarkers() + if !lineHasInlinePrompt(line, openStr, openChar, closeChar) { return } p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Position: Position{Line: pos.Line, Character: len(line)}} diff --git a/internal/lsp/handlers_helpers_test.go b/internal/lsp/handlers_helpers_test.go index 2bd677e..8a0231a 100644 --- a/internal/lsp/handlers_helpers_test.go +++ b/internal/lsp/handlers_helpers_test.go @@ -10,14 +10,14 @@ func TestHasDoubleSemicolonTrigger(t *testing.T) { line string want bool }{ - {">>todo> remove this", true}, - {"prefix >>x> suffix", true}, - {">> spaced >", false}, + {">>!todo> remove this", true}, + {"prefix >>!x> suffix", true}, + {">>! spaced >", false}, {"no markers", false}, - {">>x > space before close", false}, + {">>!x > space before close", false}, } for _, tc := range cases { - got := hasDoubleOpenTrigger(tc.line, '>', '>') + got := hasDoubleOpenTrigger(tc.line, ">!", '>', '>') if got != tc.want { t.Fatalf("hasDoubleOpenTrigger(%q)=%v want %v", tc.line, got, tc.want) } @@ -25,13 +25,13 @@ func TestHasDoubleSemicolonTrigger(t *testing.T) { } func TestCollectSemicolonMarkers(t *testing.T) { - line := "keep >ok> this and >another> that" - edits := collectSemicolonMarkers(line, 7, '>', '>') + line := "keep >!ok> this and >!another> that" + edits := collectSemicolonMarkers(line, 7, ">!", '>', '>') if len(edits) != 2 { t.Fatalf("expected 2 edits, got %d", len(edits)) } // Validate the first edit aligns with ;ok; - start := strings.Index(line, ">ok>") + start := strings.Index(line, ">!ok>") if start < 0 { t.Fatalf("test setup: missing ;ok;") } @@ -41,8 +41,8 @@ func TestCollectSemicolonMarkers(t *testing.T) { } func TestPromptRemovalEditsForLine_WholeLine(t *testing.T) { - line := ">>todo> remove this whole line" - edits := promptRemovalEditsForLine(line, 3, '>', '>') + line := ">>!todo> remove this whole line" + edits := promptRemovalEditsForLine(line, 3, ">!", '>', '>') if len(edits) != 1 { t.Fatalf("expected 1 whole-line edit, got %d", len(edits)) } diff --git a/internal/lsp/handlers_test.go b/internal/lsp/handlers_test.go index 6803d1e..b2b47c0 100644 --- a/internal/lsp/handlers_test.go +++ b/internal/lsp/handlers_test.go @@ -16,7 +16,7 @@ func TestFindFirstInstructionInLine_NoMarker(t *testing.T) { } func TestFindFirstInstructionInLine_StrictInline_Basic(t *testing.T) { - line := "prefix >rename var> suffix" + line := "prefix >!rename var> suffix" s := newTestServer() instr, cleaned, ok := s.findFirstInstructionInLine(line) if !ok { @@ -32,7 +32,7 @@ func TestFindFirstInstructionInLine_StrictInline_Basic(t *testing.T) { } func TestFindFirstInstructionInLine_StrictInline_TrailingSpacesTrimmed(t *testing.T) { - line := "code>fix> \t\t" + line := "code>!fix> \t\t" s := newTestServer() instr, cleaned, ok := s.findFirstInstructionInLine(line) if !ok { @@ -48,9 +48,9 @@ func TestFindFirstInstructionInLine_StrictInline_TrailingSpacesTrimmed(t *testin func TestFindFirstInstructionInLine_Inline_InvalidPatterns(t *testing.T) { cases := []string{ - "prefix > bad> suffix", // space after first '>' ⇒ invalid - "prefix >bad > suffix", // space before closing '>' ⇒ invalid - "prefix > > suffix", // empty inner ⇒ invalid + "prefix >! bad> suffix", // space after '!' + "prefix >!bad > suffix", // space before closing '>' ⇒ invalid + "prefix >! > suffix", // empty inner ⇒ invalid } for _, line := range cases { s := newTestServer() @@ -136,14 +136,14 @@ func TestFindFirstInstructionInLine_DoubleDash(t *testing.T) { } func TestFindFirstInstructionInLine_EarliestWins_CommentOverInline(t *testing.T) { - line := "aa // comment >not this> trailing" + line := "aa // comment >!not this> trailing" s := newTestServer() instr, cleaned, ok := s.findFirstInstructionInLine(line) if !ok { t.Fatalf("expected ok=true") } - if instr != "comment >not this> trailing" { - t.Fatalf("instr got %q want %q", instr, "comment >not this> trailing") + if instr != "comment >!not this> trailing" { + t.Fatalf("instr got %q want %q", instr, "comment >!not this> trailing") } if cleaned != "aa" { t.Fatalf("cleaned got %q want %q", cleaned, "aa") @@ -151,7 +151,7 @@ func TestFindFirstInstructionInLine_EarliestWins_CommentOverInline(t *testing.T) } func TestFindFirstInstructionInLine_EarliestWins_InlineOverComment(t *testing.T) { - line := "aa >short> // comment" + line := "aa >!short> // comment" s := newTestServer() instr, cleaned, ok := s.findFirstInstructionInLine(line) if !ok { @@ -168,19 +168,19 @@ func TestFindFirstInstructionInLine_EarliestWins_InlineOverComment(t *testing.T) func TestFindStrictInlineTag_Various(t *testing.T) { // basic - if text, l, r, ok := findStrictInlineTag("pre>do it>post", '>', '>'); !ok || text != "do it" || l != 3 || r != 10 { + if text, l, r, ok := findStrictInlineTag("pre>!do it>post", ">!", '>', '>'); !ok || text != "do it" || l != 3 || r != 11 { t.Fatalf("unexpected: ok=%v text=%q l=%d r=%d", ok, text, l, r) } // at start - if text, l, r, ok := findStrictInlineTag(">x>", '>', '>'); !ok || text != "x" || l != 0 || r != 3 { + if text, l, r, ok := findStrictInlineTag(">!x>", ">!", '>', '>'); !ok || text != "x" || l != 0 || r != 4 { t.Fatalf("unexpected at start: ok=%v text=%q l=%d r=%d", ok, text, l, r) } - // double opening '>>' should still allow a tag starting at the second '>' - if text, _, _, ok := findStrictInlineTag("prefix >>bad> suffix", '>', '>'); !ok || text != "bad" { + // double opening '>>!' should still allow a tag starting after the double marker when configured for '>!' + if text, _, _, ok := findStrictInlineTag("prefix >>!bad> suffix", ">!", '>', '>'); !ok || text != "bad" { t.Fatalf("unexpected double-open handling: ok=%v text=%q", ok, text) } // inner spaces directly after first '>' or before last '>' invalidate the tag - if _, _, _, ok := findStrictInlineTag("a> inner >b", '>', '>'); ok { + if _, _, _, ok := findStrictInlineTag("a>! inner >b", ">!", '>', '>'); ok { t.Fatalf("expected invalid strict tag due to spaces at boundaries") } } diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go index 2748a60..b3056b9 100644 --- a/internal/lsp/handlers_utils.go +++ b/internal/lsp/handlers_utils.go @@ -312,11 +312,36 @@ func (s *Server) chatWithStats(ctx context.Context, surface surfaceKind, spec re // Inline prompt utilities -func lineHasInlinePrompt(line string, open, close byte) bool { - if _, _, _, ok := findStrictInlineTag(line, open, close); ok { +func lineHasInlinePrompt(line string, openStr string, open, close byte) bool { + if openStr == "" { + openStr = string(open) + } + if _, _, _, ok := findStrictInlineTag(line, openStr, open, close); ok { return true } - return hasDoubleOpenTrigger(line, open, close) + return hasDoubleOpenTrigger(line, openStr, open, close) +} + +func doubleOpenSequences(openStr string, open, close byte) []string { + seen := make(map[string]struct{}, 2) + var seqs []string + if openStr != "" && close != 0 { + seq := openStr + string(close) + if _, ok := seen[seq]; !ok { + seen[seq] = struct{}{} + seqs = append(seqs, seq) + } + } + if openStr != "" && open != 0 { + seq := string(open) + openStr + if len(seq) > len(openStr) { + if _, ok := seen[seq]; !ok { + seen[seq] = struct{}{} + seqs = append(seqs, seq) + } + } + } + return seqs } func leadingIndent(line string) string { @@ -353,34 +378,66 @@ func applyIndent(indent, suggestion string) string { // --- Inline marker parsing and general string utilities --- -// findStrictInlineTag finds >text> (configurable), with no space after the first +// findStrictInlineTag finds >!text> (configurable), with no space after the first // opening marker and no space immediately before the closing marker. Returns the // text between markers, the start index, the end index just after closing, and ok. -func findStrictInlineTag(line string, open, close byte) (string, int, int, bool) { +func findStrictInlineTag(line string, openStr string, open, close byte) (string, int, int, bool) { + if openStr == "" { + openStr = string(open) + } + if openStr == "" { + return "", 0, 0, false + } + openChar := open + if openChar == 0 { + openChar = openStr[0] + } + doubleSeqs := doubleOpenSequences(openStr, openChar, close) pos := 0 for pos < len(line) { - // find opening marker - j := strings.IndexByte(line[pos:], open) + j := strings.IndexByte(line[pos:], openChar) if j < 0 { return "", 0, 0, false } j += pos - // ensure single open (not double) and non-space after - if j+1 >= len(line) || line[j+1] == open || line[j+1] == ' ' { + if !strings.HasPrefix(line[j:], openStr) { pos = j + 1 continue } - // find closing marker - k := strings.IndexByte(line[j+1:], close) + contentStart := j + len(openStr) + if contentStart >= len(line) { + return "", 0, 0, false + } + doubleHit := false + for _, seq := range doubleSeqs { + if strings.HasPrefix(line[j:], seq) { + doubleHit = true + contentStart += len(seq) - len(openStr) + if contentStart >= len(line) { + return "", 0, 0, false + } + break + } + } + next := line[contentStart] + if next == ' ' { + pos = contentStart + 1 + continue + } + if !doubleHit && next == close { + pos = contentStart + 1 + continue + } + k := strings.IndexByte(line[contentStart:], close) if k < 0 { return "", 0, 0, false } - closeIdx := j + 1 + k - if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { + closeIdx := contentStart + k + if closeIdx-1 >= contentStart && line[closeIdx-1] == ' ' { pos = closeIdx + 1 continue } - inner := strings.TrimSpace(line[j+1 : closeIdx]) + inner := strings.TrimSpace(line[contentStart:closeIdx]) if inner == "" { pos = closeIdx + 1 continue @@ -394,20 +451,20 @@ func findStrictInlineTag(line string, open, close byte) (string, int, int, bool) // isBareDoubleSemicolon reports whether the line contains a standalone // double-semicolon marker with no inline content (";;" possibly with only // whitespace after it). It explicitly excludes the valid form ";;text;". -func isBareDoubleOpen(line string, open, close byte) bool { +func isBareDoubleOpen(line string, openStr string, open, close byte) bool { t := strings.TrimSpace(line) - // check for double-open pattern - dbl := string([]byte{open, open}) - if !strings.Contains(t, dbl) { - return false + if openStr == "" { + openStr = string(open) } - if hasDoubleOpenTrigger(t, open, close) { + if openStr == "" { return false } - if strings.HasPrefix(t, dbl) { - rest := strings.TrimSpace(t[len(dbl):]) - if rest == "" || rest == ";" { - return true + for _, seq := range doubleOpenSequences(openStr, open, close) { + if strings.HasPrefix(t, seq) { + rest := strings.TrimSpace(t[len(seq):]) + if rest == "" || rest == string(close) { + return true + } } } return false @@ -562,40 +619,62 @@ func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit { return nil } var edits []TextEdit - _, _, openChar, closeChar := s.inlineMarkers() + openStr, _, openChar, closeChar := s.inlineMarkers() for i, line := range d.lines { - edits = append(edits, promptRemovalEditsForLine(line, i, openChar, closeChar)...) + edits = append(edits, promptRemovalEditsForLine(line, i, openStr, openChar, closeChar)...) } return edits } -func promptRemovalEditsForLine(line string, lineNum int, open, close byte) []TextEdit { - if hasDoubleOpenTrigger(line, open, close) { +func promptRemovalEditsForLine(line string, lineNum int, openStr string, open, close byte) []TextEdit { + if hasDoubleOpenTrigger(line, openStr, open, close) { return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} } - return collectSemicolonMarkers(line, lineNum, open, close) + return collectSemicolonMarkers(line, lineNum, openStr, open, close) } -func hasDoubleOpenTrigger(line string, open, close byte) bool { +func hasDoubleOpenTrigger(line string, openStr string, open, close byte) bool { + if openStr == "" { + openStr = string(open) + } + if openStr == "" { + return false + } + seqs := doubleOpenSequences(openStr, open, close) + if len(seqs) == 0 { + return false + } pos := 0 for pos < len(line) { - // look for double-open sequence - dbl := string([]byte{open, open}) - j := strings.Index(line[pos:], dbl) - if j < 0 { + found := -1 + var seq string + for _, cand := range seqs { + if cand == "" { + continue + } + if idx := strings.Index(line[pos:], cand); idx >= 0 { + abs := pos + idx + if found < 0 || abs < found { + found = abs + seq = cand + } + } + } + if found < 0 { return false } - j += pos - contentStart := j + len(dbl) + contentStart := found + len(seq) if contentStart >= len(line) { return false } first := line[contentStart] - if first == ' ' || first == open { + if first == ' ' || first == close || first == open { pos = contentStart + 1 continue } - // find closing + if contentStart+1 >= len(line) { + return false + } k := strings.IndexByte(line[contentStart+1:], close) if k < 0 { return false @@ -610,34 +689,53 @@ func hasDoubleOpenTrigger(line string, open, close byte) bool { return false } -func collectSemicolonMarkers(line string, lineNum int, open, close byte) []TextEdit { +func collectSemicolonMarkers(line string, lineNum int, openStr string, open, close byte) []TextEdit { + if openStr == "" { + openStr = string(open) + } + if openStr == "" { + return nil + } var edits []TextEdit - startSemi := 0 - for startSemi < len(line) { - j := strings.IndexByte(line[startSemi:], open) + start := 0 + doubleSeqs := doubleOpenSequences(openStr, open, close) + for start < len(line) { + j := strings.Index(line[start:], openStr) if j < 0 { break } - j += startSemi - k := strings.IndexByte(line[j+1:], close) - if k < 0 { + j += start + contentStart := j + len(openStr) + if contentStart >= len(line) { break } - if j+1 >= len(line) || line[j+1] == ' ' { - startSemi = j + 1 + next := line[contentStart] + if next == ' ' { + start = j + 1 continue } - if line[j+1] == open { // skip double-open start - startSemi = j + 2 + skipDouble := false + for _, seq := range doubleSeqs { + if strings.HasPrefix(line[j:], seq) { + skipDouble = true + break + } + } + if skipDouble { + start = j + 1 continue } - closeIdx := j + 1 + k - if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { - startSemi = closeIdx + 1 + k := strings.IndexByte(line[contentStart:], close) + if k < 0 { + break + } + closeIdx := contentStart + k + if closeIdx-1 < contentStart || line[closeIdx-1] == ' ' { + start = closeIdx + 1 continue } - if closeIdx-(j+1) < 1 { - startSemi = closeIdx + 1 + if closeIdx == contentStart { + start = closeIdx + 1 continue } endChar := closeIdx + 1 @@ -645,7 +743,7 @@ func collectSemicolonMarkers(line string, lineNum int, open, close byte) []TextE endChar++ } edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) - startSemi = endChar + start = endChar } return edits } diff --git a/internal/lsp/helpers_inline_prompt_test.go b/internal/lsp/helpers_inline_prompt_test.go index 5554d89..5dc698a 100644 --- a/internal/lsp/helpers_inline_prompt_test.go +++ b/internal/lsp/helpers_inline_prompt_test.go @@ -7,12 +7,12 @@ import ( func TestLineHasInlinePrompt_BasicAndDoubleOpen(t *testing.T) { // Basic inline - if !lineHasInlinePrompt("do >task> now", '>', '>') { - t.Fatalf("expected inline prompt detection for >text>") + if !lineHasInlinePrompt("do >!task> now", ">!", '>', '>') { + t.Fatalf("expected inline prompt detection for >!text>") } // Double-open variant should be recognized as inline prompt too - if !lineHasInlinePrompt(">>replace>", '>', '>') { - t.Fatalf("expected inline prompt detection for >>text>") + if !lineHasInlinePrompt(">>!replace>", ">!", '>', '>') { + t.Fatalf("expected inline prompt detection for >>!text>") } } diff --git a/internal/lsp/helpers_more_test.go b/internal/lsp/helpers_more_test.go index 287aa9d..683a69c 100644 --- a/internal/lsp/helpers_more_test.go +++ b/internal/lsp/helpers_more_test.go @@ -25,10 +25,10 @@ func TestLeadingAndApplyIndent(t *testing.T) { } func TestFindStrictInlineTag(t *testing.T) { - if _, _, _, ok := findStrictInlineTag(">do this> next", '>', '>'); !ok { + if _, _, _, ok := findStrictInlineTag(">!do this> next", ">!", '>', '>'); !ok { t.Fatalf("expected strict tag") } - if _, _, _, ok := findStrictInlineTag("> spaced >", '>', '>'); ok { + if _, _, _, ok := findStrictInlineTag(">! spaced >", ">!", '>', '>'); ok { t.Fatalf("should ignore spaced tag") } } @@ -81,11 +81,11 @@ func TestRangesOverlapAndOrder(t *testing.T) { } func TestPromptRemovalEditsForLine(t *testing.T) { - edits := promptRemovalEditsForLine(">>do thing>", 3, '>', '>') + edits := promptRemovalEditsForLine(">>!do thing>", 3, ">!", '>', '>') if len(edits) != 1 || edits[0].Range.Start.Line != 3 { t.Fatalf("expected full-line removal for double-semicolon") } - edits2 := promptRemovalEditsForLine(">act> and >b>", 1, '>', '>') + edits2 := promptRemovalEditsForLine(">!act> and >!b>", 1, ">!", '>', '>') if len(edits2) == 0 { t.Fatalf("expected edits to remove strict markers") } @@ -94,7 +94,7 @@ func TestPromptRemovalEditsForLine(t *testing.T) { func TestCollectPromptRemovalEdits_MultiLine(t *testing.T) { s := newTestServer() uri := "file:///t.go" - s.setDocument(uri, "a\n>do> x\n>>wipe>\nend") + s.setDocument(uri, "a\n>!do> x\n>>!wipe>\nend") edits := s.collectPromptRemovalEdits(uri) if len(edits) < 2 { t.Fatalf("expected >=2 edits, got %d", len(edits)) @@ -143,10 +143,10 @@ func TestComputeTextEditAndFilter(t *testing.T) { } func TestIsBareDoubleOpen(t *testing.T) { - if !isBareDoubleOpen(">> ", '>', '>') { + if !isBareDoubleOpen(">>! ", ">!", '>', '>') { t.Fatalf("expected true") } - if isBareDoubleOpen(">>x>", '>', '>') { + if isBareDoubleOpen(">>!x>", ">!", '>', '>') { t.Fatalf("expected false for content form") } } diff --git a/internal/lsp/init_and_trigger_test.go b/internal/lsp/init_and_trigger_test.go index 2c5cd62..832d451 100644 --- a/internal/lsp/init_and_trigger_test.go +++ b/internal/lsp/init_and_trigger_test.go @@ -72,8 +72,8 @@ func TestIsTriggerEvent_Variants(t *testing.T) { t.Fatalf("fallback char should trigger") } // 4) Bare double-open disables trigger - p4 := CompletionParams{Position: Position{Line: 0, Character: 2}} - if s.isTriggerEvent(p4, ">>") { + p4 := CompletionParams{Position: Position{Line: 0, Character: 3}} + if s.isTriggerEvent(p4, ">>!") { t.Fatalf("bare double-open should not trigger") } } diff --git a/internal/lsp/inline_prompt_completion_test.go b/internal/lsp/inline_prompt_completion_test.go index 0b71d13..9599a70 100644 --- a/internal/lsp/inline_prompt_completion_test.go +++ b/internal/lsp/inline_prompt_completion_test.go @@ -27,7 +27,7 @@ func TestHandleCompletionInlinePromptDoubleArrow(t *testing.T) { initServerDefaults(s) s.llmClient = fakeLLMInline{} uri := "file:///inline.go" - line := "hello world >>translate this into bulgarian>" + line := "hello world >>!translate this into bulgarian>" s.setDocument(uri, line) p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Position: Position{Line: 0, Character: len(line)}} ctx := struct { diff --git a/internal/lsp/instruction_table_test.go b/internal/lsp/instruction_table_test.go index a6042b1..542bc68 100644 --- a/internal/lsp/instruction_table_test.go +++ b/internal/lsp/instruction_table_test.go @@ -8,7 +8,7 @@ func TestFindFirstInstructionInLine_Table(t *testing.T) { line string instr string }{ - {"strict_inline_marker", ">do> trailing", "do"}, + {"strict_inline_marker", ">!do> trailing", "do"}, {"c_block", "x /* add docs */ y", "add docs"}, {"html_comment", "<!-- fix --> code", "fix"}, {"slash_slash", "code // please refactor", "please refactor"}, diff --git a/internal/lsp/postprocess_indent_test.go b/internal/lsp/postprocess_indent_test.go index 28f73a5..f00fb74 100644 --- a/internal/lsp/postprocess_indent_test.go +++ b/internal/lsp/postprocess_indent_test.go @@ -4,7 +4,7 @@ import "testing" func TestPostProcessCompletion_IndentWithDoubleOpen(t *testing.T) { s := newTestServer() - cleaned := s.postProcessCompletion("a\nb", "", " >>gen>") + cleaned := s.postProcessCompletion("a\nb", "", " >>!gen>") // Expect each non-empty line to be indented by two spaces want := " a\n b" if cleaned != want { diff --git a/internal/lsp/provider_native_success_test.go b/internal/lsp/provider_native_success_test.go index e5ab81e..5bfe434 100644 --- a/internal/lsp/provider_native_success_test.go +++ b/internal/lsp/provider_native_success_test.go @@ -50,7 +50,7 @@ func TestProviderNativeCompletion_IndentWithDoubleOpen(t *testing.T) { s := newTestServer() s.llmClient = fakeCompleterIndent{} spec := s.buildRequestSpec(surfaceCompletion) - current := " >>do>" // leading indent + double-open marker + current := " >>!do>" // leading indent + double-open marker p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x.go"}, Position: Position{Line: 0, Character: len(current)}} plan := completionPlan{current: current, params: p, funcCtx: "func f(){}", docStr: "doc", cacheKey: "k"} items, ok := s.tryProviderNativeCompletion(context.Background(), plan, spec, s.llmClient, "0000") diff --git a/internal/lsp/server.go b/internal/lsp/server.go index d55a967..e3a21f3 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -448,7 +448,7 @@ func (s *Server) inlineMarkers() (open string, close string, openChar byte, clos cfg := s.currentConfig() open = strings.TrimSpace(cfg.InlineOpen) if open == "" { - open = ">" + open = ">!" } close = strings.TrimSpace(cfg.InlineClose) if close == "" { diff --git a/internal/lsp/triggers_config_test.go b/internal/lsp/triggers_config_test.go index 96ac4ba..9ab2752 100644 --- a/internal/lsp/triggers_config_test.go +++ b/internal/lsp/triggers_config_test.go @@ -31,7 +31,7 @@ func TestNewServer_AssignsTriggerGlobals_AndParsingUsesThem(t *testing.T) { s := NewServer(bytes.NewReader(nil), &out, log.New(io.Discard, "", 0), ServerOptions{ InlineOpen: "<", InlineClose: ">", ChatSuffix: ")", ChatPrefixes: []string{":"}, }) - _, _, openChar, closeChar := s.inlineMarkers() + openStr, _, openChar, closeChar := s.inlineMarkers() if openChar != '<' || closeChar != '>' { t.Fatalf("inline markers not applied: %q %q", string(openChar), string(closeChar)) } @@ -39,7 +39,7 @@ func TestNewServer_AssignsTriggerGlobals_AndParsingUsesThem(t *testing.T) { if suffixChar != ')' || len(prefixes) == 0 || prefixes[0] != ":" { t.Fatalf("chat markers not applied: suffix=%q prefixes=%v", string(suffixChar), prefixes) } - if txt, l, r, ok := findStrictInlineTag("x<do>y", openChar, closeChar); !ok || txt != "do" || l != 1 || r != 5 { + if txt, l, r, ok := findStrictInlineTag("x<do>y", openStr, openChar, closeChar); !ok || txt != "do" || l != 1 || r != 5 { t.Fatalf("findStrictInlineTag failed: ok=%v txt=%q l=%d r=%d", ok, txt, l, r) } if got := s.stripTrailingTrigger("note:)"); got != "note:" { diff --git a/internal/runtimeconfig/store_test.go b/internal/runtimeconfig/store_test.go index 1c05cc9..0a0183a 100644 --- a/internal/runtimeconfig/store_test.go +++ b/internal/runtimeconfig/store_test.go @@ -26,6 +26,7 @@ func TestStoreReloadSkipsEnvOverrides(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmp) t.Setenv("HEXAI_MAX_TOKENS", "321") + t.Setenv("HEXAI_PROVIDER", "") initial := appconfig.Load(logger) if initial.MaxTokens != 321 { @@ -75,6 +76,7 @@ func TestStoreReloadLogsSummary(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmp) t.Setenv("HEXAI_MAX_TOKENS", "321") + t.Setenv("HEXAI_PROVIDER", "") initial := appconfig.Load(logger) store := New(initial) diff --git a/internal/version.go b/internal/version.go index a28ebba..021cc9d 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,4 +1,4 @@ // Summary: Hexai semantic version identifier used by CLI and LSP binaries. package internal -const Version = "0.15.0" +const Version = "0.15.1" |
