Skip to content

Conversation

@marciobbj
Copy link

Add Writers Mode & Model Selection

Intro

This PR introduces Writers Mode, an alternative operating mode focused on text editing and improvement, along with complementary features like dynamic model selection. The 99 plugin was exclusively focused on code assistance. This update expands its utility to text correction, enabling:

  • Text correction and improvement in multiple languages
  • Quick switching between code/writer modes
  • Dynamic LLM model selection

Changes Implemented

1. Mode System (Code/Writer)

Files: lua/99/init.lua, lua/99/prompt-settings.lua

Refactoring of the prompt system to support multiple modes:

-- Before: fixed code prompts
prompts = require("99.prompt-settings")

-- After: mode system
prompt_settings = {
    modes = {
        code = code_prompts,
        writer = writer_prompts,
    }
}

Public API Added:

  • require("99").toggle_mode() - Toggle between code/writer
  • require("99").set_mode("writer") - Set specific mode
  • require("99").set_writer_language("pt-BR") - Set language

2. Automatic Language Detection

File: lua/99/init.lua

New function that automatically detects the user's language:

local function get_default_language()
    -- 1. Try buffer's spelllang
    local spelllang = vim.api.nvim_get_option_value("spelllang", { scope = "global" })
    if spelllang and spelllang ~= "" then
        return spelllang
    end
    -- 2. Fallback to system $LANG
    local lang = vim.env.LANG
    if lang then
        return lang:sub(1, 5):gsub("_", "-")
    end
    return "en"
end

Priority order:

  1. Current buffer's :set spelllang
  2. $LANG environment variable
  3. Fallback to "en"

3. Specialized Writing Prompts

File: lua/99/prompt-settings.lua

New prompt set optimized for text editing, prompts are dynamic and incorporate the detected language:

local writer_prompts = create_prompts(
    function(lang)
        return string.format("You are a professional writing assistant... in %s", lang)
    end,
    -- ...
)

4. Dynamic Model Selection

File: lua/99/init.lua

New select_model() function that lists all available models via opencode. This is the largest addition in this PR.

Step 1: Async System Call to OpenCode

function _99.select_model()
    vim.system({ "opencode", "models" }, { text = true }, function(obj)
        if obj.code ~= 0 then
            vim.schedule(function()
                vim.notify(
                    "Failed to list models: " .. (obj.stderr or "unknown error"),
                    vim.log.levels.ERROR
                )
            end)
            return
        end
        -- continues...
    end)
end

Uses vim.system for async execution, preventing UI blocking.

Step 2: Parse Model List from stdout

local models = {}
for line in obj.stdout:gmatch("[^\r\n]+") do
    local m = line:gsub("^%s*", ""):gsub("%s*$", "")
    if m ~= "" then
        table.insert(models, m)
    end
end

if #models == 0 then
    vim.schedule(function()
        vim.notify("No models found", vim.log.levels.WARN)
    end)
    return
end

Parses each line from stdout, trims whitespace, and builds model list.

Step 3: Fallback Input Function

local function fallback_input()
    vim.ui.input({
        prompt = "Enter model name (large list fallback): ",
        default = _99_state.model,
    }, function(input)
        if input and input ~= "" then
            _99.set_model(input)
            vim.notify("99 Model set to: " .. input)
        end
    end)
end

Safety fallback when vim.ui.select fails or list is too large.

Step 4: Small List - Use vim.ui.select

if #models < 50 then
    local ok, err = pcall(vim.ui.select, models, {
        prompt = "Select 99 Model:",
    }, function(choice)
        if choice then
            _99.set_model(choice)
            vim.notify("99 Model set to: " .. choice)
        end
    end)
    if not ok then
        fallback_input()
    end
    return
end

For lists under 50 items, uses native vim.ui.select with pcall protection.

Step 5: Large List - Custom Buffer Picker

-- For large lists, create a temporary buffer to avoid snacks.nvim bugs
local win, _ = Window.create_centered_window()
vim.api.nvim_buf_set_lines(win.buf_id, 0, -1, false, models)
vim.api.nvim_buf_set_name(win.buf_id, "99-model-selector")
vim.bo[win.buf_id].modifiable = false
vim.bo[win.buf_id].buftype = "nofile"

vim.notify("99: Use <Enter> to select a model, 'q' to cancel", vim.log.levels.INFO)

vim.keymap.set("n", "<CR>", function()
    local cursor = vim.api.nvim_win_get_cursor(win.win_id)
    local choice = models[cursor[1]]
    Window.clear_active_popups()
    if choice then
        _99.set_model(choice)
        vim.notify("99 Model set to: " .. choice)
    end
end, { buffer = win.buf_id })

vim.keymap.set("n", "q", function()
    Window.clear_active_popups()
end, { buffer = win.buf_id })

Custom buffer picker that avoids snacks.nvim integral height bug with large lists.

Usage:

:lua require("99").select_model()

5. Temporary File Management Fix

File: lua/99/utils.lua

- return string.format("%s/tmp/99-%d", vim.uv.cwd(), ...)
+ local tmp_dir = "/tmp/99"
+ if vim.fn.isdirectory(tmp_dir) == 0 then
+     vim.fn.mkdir(tmp_dir, "p")
+ end
+ return string.format("%s/%d", tmp_dir, ...)

Motivation:

  • Avoids creating tmp/ directory inside the project
  • Uses system's standard /tmp/99 directory
  • Automatically creates directory if it doesn't exist

How to Test

  1. Open a text file (.md, .txt)
  2. Run :lua require("99").set_mode("writer")
  3. For text correction select the text in Visual Mode
  4. Press <leader>9v and wait.

For model selection (default is claude-sonnet-4.5):

:lua require("99").select_model()

Design Decisions

Unified context Parameter in Prompt Functions

The context argument was added to all prompt functions (implement_function, fill_in_function, visual_selection) to enable dynamic language detection via get_lang(context):

local function get_lang(context)
    if not context or not context._99 then
        return "en"
    end
    local buf_spelllang = vim.api.nvim_get_option_value("spelllang", { buf = context.buffer })
    if buf_spelllang and buf_spelllang ~= "" and buf_spelllang ~= "en" then
        return buf_spelllang
    end
    return context._99.writer_language or "en"
end

Why pass context to code mode if it doesn't use language?

The code_prompts use fixed strings while writer_prompts use functions that receive lang:

Mode Prompt Type Uses context?
writer Functions f(lang) ✅ Yes - for language detection
code Fixed strings ❌ No - lang is ignored

The resolve() function handles this transparently:

local function resolve(val, ...)
    if type(val) == "function" then
        return val(...)  -- calls with lang for writer mode
    end
    return val  -- returns string directly for code mode
end

Decision: Pass context to all prompt functions to maintain a unified API. This avoids having two different function signatures and simplifies the calling code in operations like implement-fn.lua and over-range.lua. The overhead is negligible since get_lang() short-circuits when the value isn't used.


Known Issues

  • Ensure clear separation of the fill-in functionality to prevent execution from writers mode, and implement proper error handling.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant