Skip to content

feat(chat): add contextual key instructions to ChatInput and MessageHistory#258

Merged
LeeCheneler merged 5 commits intomajor-refactorfrom
feat/instruction-bar
Apr 1, 2026
Merged

feat(chat): add contextual key instructions to ChatInput and MessageHistory#258
LeeCheneler merged 5 commits intomajor-refactorfrom
feat/instruction-bar

Conversation

@LeeCheneler
Copy link
Copy Markdown
Owner

Summary

Add a reusable KeyInstructions component and wire it into ChatInput and MessageHistory to show
contextual keyboard hints that adapt to the current state — what keys are available changes based
on input content, cursor position, escape-pending state, and history navigation position.

GitHub Issue

N/A

What Changed

A new KeyInstructions component renders a row of yellow key + dim description pairs separated by
· dividers. Both ChatInput and MessageHistory use it to show right-aligned, state-aware hints:

ChatInput shows instructions conditionally:

  • enter submit · escape clear when there's content
  • escape confirm when escape is pending (double-tap to clear)
  • up history when message history exists and cursor is at position 0
  • Empty bar (reserved height) when input is empty — no layout jumping

MessageHistory adapts instructions to navigation position:

  • up next / down previous only appear when there are entries in that direction
  • esc/down return to draft combines on the last entry since both keys exit
  • esc return to draft when not on the last entry

useHistory refactor: switched from a ref-based HistoryApi class to useState + useCallback
so that push triggers re-renders. This eliminated a hasHistory state workaround in useChat
history.entries.length > 0 now works directly as the prop derivation.

Also exported flushInkFrames from test utils for tests that need to manually flush Ink re-renders
after direct state updates.

LeeCheneler and others added 4 commits April 1, 2026 23:59
Renders a row of yellow key + dim description pairs separated by · dividers.
Will be used by ChatInput and MessageHistory to show contextual instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Replace the hardcoded escape hint with a KeyInstructions bar that shows
relevant key actions based on input state: enter/submit and escape/clear
when content is present, escape/confirm when pending, and up/history
when message history is available. Rename InstructionBar to KeyInstructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…ne ChatInput hints

MessageHistory shows up/down/esc/enter instructions that adapt to
position: esc/down combines on the last entry, up/down only appear
when navigation is possible. ChatInput now shows "up history" only
at cursor position 0 and updates immediately after first message send.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…round

Switch useHistory from ref-based HistoryApi class to useState so push
triggers re-renders. This lets Chat derive hasHistory directly from
history.entries.length instead of tracking it as separate state.
Export flushInkFrames from test utils for tests needing manual flush.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Instruction building logic belongs in hooks, not components. Move it
into useChatInput and useMessageHistory with a declarative filter
pattern. Components now receive instructions as data and render only.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@LeeCheneler LeeCheneler merged commit 6f9d9f8 into major-refactor Apr 1, 2026
3 checks passed
@LeeCheneler LeeCheneler deleted the feat/instruction-bar branch April 1, 2026 23:55
LeeCheneler added a commit that referenced this pull request Apr 9, 2026
* docs: add architecture guidelines and repository restructure plan

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor: move old codebase to old/, bootstrap new src/ shell

- Move existing source to old/ as reference implementation
- Create new src/ with Ink shell app, config hook, and app header
- Add CLAUDE.md with coding guidelines, comment rules, and testing rules
- Add coverage/ to .gitignore
- Add node types to tsconfig

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* chore: remove .js extensions from imports and add guideline

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(config): add zod config schema with nullish model/provider

- Add config schema and inferred Config type
- Update useConfig hook to return typed Config with null defaults
- Update AppHeader to show fallback when model/provider missing
- Add tests for schema, hook, and header fallback states
- Restrict vitest to src/ only via vitest.config.ts

* refactor: extract version into its own module

* feat(ui): add layout, typography, and theme primitives

- Add Indent and BlankLine layout components
- Add Heading and Hint typography components
- Add theme color palette for consistent theming
- Refactor AppHeader to use primitives and co-located hook

* fix(ui): fix BlankLine double-line output and style header version info

* refactor(ui): simplify BlankLine to single line with no props

* feat(config): add full config schema with zod validation (#249)

* feat(config): add provider schema and types

* feat(config): add permissions schema with cwd/global file access

* feat(config): add tools schema and extract sub-schemas for isolation

* feat(config): add allowedCommands schema

* feat(config): add agents schema with maxTimeoutSeconds

* feat(config): add MCP connections schema with stdio and HTTP transports

* feat(config): add skillSets schema with sources and nested enabledSets

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat(config): add file loading, saving, and typed updaters (#253)

* feat: add fs utils, test utils for mock filesystem, and config file paths

* feat(config): add loadConfig with YAML loading, merging, and validation

* feat(config): add saveGlobalConfig and saveLocalConfig for whole file writes

* feat(config): add updateGlobalConfig and updateLocalConfig read-modify-write primitives

* feat(config): add typed updaters with zod-validated reads, zero casts

* feat(config): add typed updaters with zod-validated reads, zero casts

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat: add ChatInput component and useTextInput hook (#255)

* feat(ui): add ChatInput component with bordered input and status ribbon

Refs #254

* feat(ui): add keyboard input handling to ChatInput

Refs #254

* feat(ui): add cursor tracking with visible block cursor and left/right movement

Refs #254

* feat(ui): add option+arrow and ESC+b/f word jump for cursor

Refs #254

* feat(ui): add shift+enter newline support in ChatInput

Refs #254

* feat(ui): add up/down arrow for cursor start/end navigation

Refs #254

* refactor(ui): extract useTextInput hook into shared input/text module

Refs #254

* refactor(input): extract useTextInput to src/input and fix word boundary detection

Moves the shared text input hook out of src/ui/input into src/input.
Word jump now uses \w to detect word boundaries, stopping at
punctuation and newlines instead of only spaces.

Refs #254

* feat(input): add lineMode option to useTextInput

Refs #254

* feat(input): add onUp/onDown boundary callbacks to useTextInput

Refs #254

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat(chat): add useChatInput hook with multi-line cursor navigation (#256)

* feat(chat): add useChatInput hook with basic state and submit

Refs #254

* refactor(chat): co-locate ChatInput component and hook, add history up navigation

Merges ChatInput component and useChatInput hook into a single
co-located file at src/chat/chat-input.tsx. Removes status bar,
exposes setCursor from useTextInput, and adds history storage
with up arrow navigation.

Refs #254

* refactor(chat): strip message history from ChatInput

History will be a separate MessageHistory component with its own
rendering and navigation, not embedded in the input hook.

Refs #254

* feat(input): add multi-line cursor navigation and fix newline cursor rendering

Up/down arrows now move between lines at the same column, clamping
to line length on shorter lines. First line up goes to start, last
line down goes to end, with boundary callbacks at the edges.

Extracts splitAtCursor for testable cursor rendering on newlines
and end-of-value.

Refs #254

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat(chat): add message history browsing and input draft preservation (#257)

* feat(chat): add onUp and initialValue props to ChatInput

* feat(chat): add useHistory hook for message history storage

* feat(chat): add double-escape to clear input with confirmation hint

* feat(chat): add MessageHistory component for browsing sent messages

* feat(chat): add Chat router with history mode and magenta theme

* fix(chat): remove unused vi import from chat test

* refactor(test): add renderInk wrapper and keys constants for readable tests

* feat(chat): preserve draft input when switching to history mode

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat(chat): add contextual key instructions to ChatInput and MessageHistory (#258)

* feat(ui): add InstructionBar component for key-action hints

Renders a row of yellow key + dim description pairs separated by · dividers.
Will be used by ChatInput and MessageHistory to show contextual instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(chat): add KeyInstructions to ChatInput with contextual hints

Replace the hardcoded escape hint with a KeyInstructions bar that shows
relevant key actions based on input state: enter/submit and escape/clear
when content is present, escape/confirm when pending, and up/history
when message history is available. Rename InstructionBar to KeyInstructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(chat): add contextual KeyInstructions to MessageHistory and refine ChatInput hints

MessageHistory shows up/down/esc/enter instructions that adapt to
position: esc/down combines on the last entry, up/down only appear
when navigation is possible. ChatInput now shows "up history" only
at cursor position 0 and updates immediately after first message send.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(chat): make useHistory state-driven, remove hasHistory workaround

Switch useHistory from ref-based HistoryApi class to useState so push
triggers re-renders. This lets Chat derive hasHistory directly from
history.entries.length instead of tracking it as separate state.
Export flushInkFrames from test utils for tests needing manual flush.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(chat): move instruction derivation into hooks

Instruction building logic belongs in hooks, not components. Move it
into useChatInput and useMessageHistory with a declarative filter
pattern. Components now receive instructions as data and render only.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Lee Cheneler <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>

* feat(chat): add ChatList with user message rendering (#259)

* feat(chat): add ChatMessage type with user message variant

* feat(chat): add ChatList component and wire user messages into Chat

Add ChatList using Ink's Static to render submitted messages above the
input. User messages display with a cyan ❯ indicator in a flex layout
so multi-line content aligns correctly. Wire into Chat so submitted
messages persist in the list. Also align ChatInput prompt the same way
and add spacing below the app header.

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat(commands): add slash command infrastructure with autocomplete (#260)

* feat(commands): add command registry for slash commands

* feat(chat): add CommandMessage type and chat list rendering

* feat(commands): add command parsing, execution, and /ping command

Registry invoke handles parsing the command name, executing the async
handler, and returning a CommandMessage. Unknown commands are styled
with the theme error color. Chat delegates detection to isCommand and
execution to the registry. Ping command wired as first built-in.

* feat(chat): add inline autocomplete for slash commands

AutocompleteList renders filtered suggestions below ChatInput while
typing a / command. Up/down navigate the list (captureUpDown on
useTextInput), enter fills the selected command with a trailing space
to dismiss. Space in the input hides autocomplete. Instructions
swap between navigate/select and normal submit/clear/history modes.

* fix(chat): use sliding window for autocomplete scrolling

Selection now moves through all filtered items, not just the first 5.
The visible window slides lazily when the cursor hits the top or bottom
edge. Extracted getWindowStart and removed the slice from filterAutocompleteItems
so windowing is handled by the caller.

* refactor(chat): extract autocomplete state into useAutocompleteNavigation

Move selectedIndex, windowStart, filtering, and navigation logic out of
useChatInput into a dedicated hook in the autocomplete module. ChatInput
now delegates to moveUp/moveDown/select/reset instead of managing
autocomplete internals directly.

* chore: remove unused AutocompleteNavigation import

* refactor(chat): make key instructions static in both chat modes

Replace dynamic, state-dependent instructions with static sets. ChatInput
always shows enter/submit, escape/clear, up/history, and /command.
MessageHistory always shows up/down/scroll, esc/return to draft, and
enter/replace draft. Only escape confirm and the autocomplete mode swap
remain dynamic. Removes significant conditional logic.

* chore: remove temporary test command registrations

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat(input): add word delete support (Option+Backspace and Meta+d)

Reuses existing word boundary functions to delete whole words, mirroring the
Option+Left/Right word jump behaviour.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(settings): add /settings command with generic takeover mode

Introduces a takeover mechanism for commands that need to take over the chat
screen. The registry returns a discriminated InvokeResult (inline or takeover)
and Chat renders the takeover component without knowing what it is. The
/settings command uses this to render a navigation menu with placeholder
sub-screens.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(ui): extract NavigationMenu from Settings

Reusable keyboard-navigable menu component with cursor indicator,
clamped selection, and configurable color. Settings now delegates
menu rendering and input handling to NavigationMenu.

* feat(ui): add looping navigation to NavigationMenu

* feat(settings): add ToggleList component and Permissions screen

Add reusable ToggleList UI primitive with looping cursor navigation and
space/enter toggling. Implement Permissions settings screen that loads
config, renders four file-access permission toggles, and persists
changes to local config immediately on toggle.

Wire Permissions into the settings router. Settings now always returns
"Settings updated" via onDone. Fix takeover command name bug where
/settings displayed as "/" in message history by storing the command
name in takeover mode state. Add dimmed cursor prefix to command
messages matching user message style.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(settings): add Tools screen with enable/disable toggles

Implement ToolsScreen that displays all 10 first-party tools as a
toggle list, persisting changes to local config. Extra fields like
webSearch.apiKey are preserved when toggling enabled state.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(ui): add theme.key color, toggle list styling and options support

Add theme.key color and use it in KeyInstructions and ToggleList.
Style toggle indicators with dim brackets and green checkmarks.
Add hasOptions/onOptions support to ToggleList — items with options
show a yellow › indicator and fire onOptions on Tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(ui): add Form component and tool options sub-screen

Add generic Form component with toggle and text field types. Up/down
navigates fields, space toggles, typing edits text inline, enter
submits, escape cancels.

Replace ToolOptionsScreen with Form-based options sub-screen in
ToolsScreen. Web Search options form includes Enabled toggle and
API Key text field. Make space the only toggle key across ToggleList
and Form for consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* test(keys): add keys.space constant and replace raw space literals

Replace all stdin.write(" ") calls with stdin.write(keys.space) for
consistency with other key constants in the test utility.

* chore(test): enforce 100% coverage thresholds in vitest config

* feat(ui): improve escape-to-clear UX with yellow highlight and shorten key labels

Replace dynamic escape/confirm instruction swap with static "esc clear".
First escape highlights input text in yellow inverse to signal pending
clear. Standardise all key instruction labels from "escape" to "esc".

* refactor: remove unnecessary v8 ignore statements

Simplify buildFormFields and applyFormValues to remove dead-code
branching — only webSearch has options currently. Replace redundant
type guards with simpler control flow. Reduces v8 ignore count from
12 to 5, keeping only those required for TypeScript type narrowing.

* feat(settings): add Allowed Commands screen with EditableList component

Add a reusable EditableList component for inline editing of string
lists — items are editable when focused, enter saves/adds/removes,
and navigating away discards unsaved edits. Wire it up as the Allowed
Commands settings screen with config persistence and :* prefix syntax
guidance.

* refactor(ui): extract shared Border component to replace duplicated helpers

* refactor(input): extract cursor utilities and rename text hook

Extract splitAtCursor, word boundary, and line info helpers from
text.ts into shared cursor.ts module. Replace duplicated splitAtCursor
in chat-input, form, and editable-list with shared import. Rename
text.ts to text-input.ts to match the hook name.

* refactor(input): replace useTextInput with shared processTextEdit

Add multi-line support (shift+enter, up/down line nav) to processTextEdit
and eliminate the useTextInput hook. All three consumers (chat-input, form,
editable-list) now use processTextEdit directly, gaining word operations
for free. Net reduction of ~600 lines.

* fix(input): strip newlines from input in single-line mode

* feat(provider): add provider client and settings screen (#262)

* feat(provider): add provider client interface and types

Define the ProviderClient interface (fetchModels, fetchContextWindow,
streamCompletion), OpenAI-compatible message types, streaming types,
default base URLs for all three provider types, and resolveApiKey
utility with env var fallbacks.

* feat(provider): add OpenAI-compatible client with fetchModels and MSW test setup

Add createOpenAICompatibleClient factory that implements ProviderClient
for all three provider types. Includes fetchModels with provider-specific
endpoint selection, auth headers, and response format handling. Set up
MSW for network mocking in tests.

* feat(ui): add select field to Form and options support to EditableList

Add SelectFormField with inline radio-style options navigated via
left/right arrows. Extend EditableList with hasOptions, onOptions,
and the yellow › indicator matching ToggleList's pattern. Add
updateProvider config updater for modifying existing providers.

* feat(settings): add providers list screen

* feat(settings): add provider options form with auto-open on add

* feat(settings): add connection status check after saving provider options

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat(ui): add LoadingIndicator with spinner and shimmer animation

* feat(model): add /model command for provider and model selection (#263)

* feat(model): add /model command with placeholder takeover screen

* feat(model): add provider selection screen to model selector

* feat(model): add model list with fetch, selection, and config persistence

Replace the model list placeholder with real model fetching via
createOpenAICompatibleClient. Shows loading indicator, error, and
empty states. Model selection updates activeProvider and activeModel
in global config. Fix useConfig to load from disk instead of
returning hardcoded defaults.

* refactor(config): replace direct loadConfig calls with useConfig hook

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat(chat): add streaming LLM completions with abort support (#264)

* feat(provider): add SSE stream parser

* feat(provider): implement streamCompletion with SSE parsing and zod validation

* feat(chat): add streaming completion with SSE parser and useCompletion hook

Add SSE stream parser, implement streamCompletion in the OpenAI-compatible
client with zod-validated chunk parsing, and introduce useCompletion hook
for managing the streaming lifecycle (idle/streaming/complete/error).
Fix leaked network requests in provider and model selector tests.

* feat(chat): wire streaming completions into chat UI

Integrate useCompletion hook into the chat loop. User messages trigger
streaming completions, with live content rendered below the message
list alongside a loading indicator. Assistant messages append to
history on completion. Errors render in red via a dedicated ErrorMessage
type. Fix env var leakage in provider tests.

* feat(chat): add stream abort with escape key and interrupted notice

Escape during streaming aborts the completion, keeping partial content.
When not streaming, escape falls through to the existing clear behaviour.
Adds dedicated "aborted" state to the completion lifecycle and
"interrupted" message type rendered as dimmed text in the chat.

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat(commands): add /context command with real context window detection

Add CommandContext to the registry so handlers receive runtime state.
Implement /context with a progress bar showing token usage against the
real context window. Fetch context window from Ollama /api/show or
/v1/models metadata with zod validation. Remove /ping command.

* feat(prompt): add system prompt builder with context injection (#265)

* feat(prompt): add system info builder

Adds getSystemInfo() which builds a one-line system context string
with OS, release, architecture, shell, username, and cwd for
injection into the LLM system prompt.

* feat(prompt): add git context builder

Adds git helper functions (isGitRepo, getGitBranch, getDefaultBranch,
getGitStatusSummary, getGitLog, isGitHubRemote, isGhCliAvailable) and
getGitContext() which composes them into a formatted string for the
system prompt including branch, status, recent commits, and GitHub hints.

* feat(prompt): add tomo.md instructions loader

Adds loadInstructions() which reads global ~/tomo.md and local
.tomo/tomo.md instruction files. When both exist they are joined
with a separator. Empty/whitespace-only files are ignored.

* feat(prompt): wire system prompt into chat completions

Adds buildSystemPrompt() which composes system info, git context, and
tomo.md instructions into a single string. The system message is
prepended at index 0 of every completion request. The prompt is rebuilt
on each send so git status stays current.

Includes TODO stubs for tool guidance, agent orchestration, and skills
sections to be added after those features are implemented.

* refactor(prompt): rename mockGitCommands to mockCommands

The helper mocks any execSync call by substring matching, not just
git commands. The name now reflects its actual scope.

* refactor(provider): move buildProviderMessages to provider module

Extracts message building from chat.tsx into provider/messages.ts
where it belongs alongside other provider concerns. This keeps
chat focused on UI state and prepares for the agentic tool loop
which will add complexity to message construction.

---------

Co-authored-by: Lee Cheneler <[email protected]>

* refactor(config): replace useConfig with React Context and reload mechanism (#266)

* refactor(config): replace useConfig with React Context

Converts useConfig from a simple useMemo hook into a React Context
with ConfigProvider and a reload() function. All consumers now get
config from context and can trigger a re-read from disk.

The renderInk test utility now wraps all trees in ConfigProvider
with automatic mock config and cleanup (afterEach unmounts Ink and
restores the mock filesystem). Tests pass config overrides via the
second argument instead of calling mockConfig manually.

* feat(config): wire reload() into mutation sites and remove prop drilling

All settings screens and the model selector now call reload() after
writing config to disk, keeping the React context in sync. Chat reads
provider and model from useConfig() instead of receiving them as props.

The renderInk test utility exposes getConfig() which reads the live
context value, used to assert that reload() was called after mutations.

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat(markdown): add terminal markdown rendering and ANSI stripping (#267)

* feat(utils): add stripAnsi and strip ANSI from provider messages

Adds a stripAnsi utility for removing ANSI escape codes from strings.
Assistant messages are now stripped before being sent to the provider
to prevent rendered markdown formatting from wasting context tokens.

* feat(markdown): add terminal markdown rendering for chat messages

Adds renderMarkdown() and completePartialMarkdown() for converting
markdown to ANSI-formatted terminal output using marked, chalk, and
cli-highlight. Supports headings, inline styles, code blocks with
syntax highlighting, tables with box drawing, blockquotes, and lists.

Assistant messages in the static list render through renderMarkdown.
Streaming content uses completePartialMarkdown to close unclosed code
fences before rendering, so partial responses display correctly.

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat(session): add session persistence with /new and /session commands (#268)

* feat(session): write chat messages to JSONL session files

Each chat session gets a unique JSONL file at ~/.tomo/sessions/<datetime>-<uuid>.jsonl.
Every message (user, assistant, command, error, interrupted) is appended as a JSON line
as it occurs. Also fixes pre-existing coverage gaps in config hook and test utils.

* feat(commands): add /new command to start a fresh session

Adds resetSession to CommandContext so commands can reset the
conversation. The session path swap is deferred until after the command
result is appended, keeping the result visible in Static and written
to the old session file. A sessionStartIndex ref prevents old messages
from being sent to the LLM after a reset.

* refactor(ui): rename NavigationMenu to SelectList

* feat(commands): add /session command with terminal reset on switch

Adds /session to browse and load saved sessions, and loadSession to
CommandContext so takeover commands can replace the conversation.
TakeoverRender now receives CommandContext as a second argument.

The app header moves into ChatList's Static so both header and messages
share a single key. On session switch the terminal clears via ANSI
escape and the Static remounts, giving a clean slate.

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat(tools): add tool system with read_file, agentic loop, and permission UI (#269)

* feat(tools): add tool system architecture and read_file tool

Introduces the tool type system (Tool, ToolContext, ToolResult), a
tool registry that produces OpenAI-compatible definitions, and a
permission checker for cwd/global file access. The read_file tool
is the first implementation — it reads files with numbered lines,
supports line ranges, truncates at 500 lines, and gates on file
permissions with interactive confirmation fallback.

* chore: remove unused Permissions import

* feat(tools): wire agentic completion loop with tool execution

Adds the send → tool calls → execute → send results → repeat loop
to useChat. Tool calls and results are tracked as ChatMessage types
so they persist in conversation context across turns.

Fixes SSE delta parsing to accept content: null (sent by providers
alongside tool calls). Renders tool calls and results in the chat
list. Tool execution errors are caught and sent as tool results
rather than silently killing the loop.

* style(ui): improve tool call and result rendering in chat list

* feat(tools): add interactive approval UI and tool denial guidance

Reworks the confirm prompt into a bordered panel with a select list,
y/n keyboard shortcuts, and escape-to-deny. Shows "Awaiting approval"
below the tool call while waiting for user input. Adds system prompt
guidance telling the LLM to respect tool denials and not retry them.

---------

Co-authored-by: Lee Cheneler <[email protected]>

* feat(tools): render tool errors and denials in red

Add status field to ToolResultMessage and use it in the chat list to
visually distinguish ok/error/denied tool results. Errors and denials
render in theme.error (red) instead of dimColor.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(tools): add diff rendering, write_file tool, and tool-controlled call summaries

- Add format field ("plain" | "diff") to ToolResult, carried through to
  ToolResultMessage for colored diff rendering (+green, -red, @@cyan)
- Extract DiffView to shared ui component used by chat list and confirm UI
- Add write_file tool with permission gating and diff preview on confirmation
- Generate diff before writing so users see changes before approving
- Replace generic formatArgs with per-tool formatCall method so each tool
  controls its own display summary (read_file/write_file show just the path)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(tools): add edit_file tool and fill test coverage gaps

- Add edit_file tool with exact string replacement, uniqueness check,
  diff preview on confirmation, and write permission gating
- Add formatCall tests for read_file, write_file, and edit_file
- Add chat.tsx test for diff rendering in confirm prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(tools): extract tool execution loop from Chat into standalone function

Move tool call execution logic out of useChat into executeToolCalls(),
a pure async function that takes tool calls and returns display messages.
This allows unit testing tool execution without rendering Chat with MSW,
and removes ~200 lines of integration tests now covered by unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(tools): add glob tool for file pattern matching

Uses git ls-files in git repos for .gitignore support, falls back to
fs.globSync outside git or when gitignore is disabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(tools): add grep tool for file content search

Uses git grep in git repos for .gitignore support, falls back to
regular grep outside git or when gitignore is disabled. Supports
include filters, single file search, and extended regex (-E).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(utils): extract getErrorMessage utility and fix coverage gaps

Add shared getErrorMessage() for extracting messages from unknown caught
values. Replace inline instanceof checks in glob, grep, and
execute-tool-calls. Add missing branch coverage tests for custom path
and include-with-slash. Simplify tool defs passthrough in chat loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(tools): add ask tool with interactive prompt UI

- Add ask tool with option selection and free-text input modes
- Add AskPrompt component using SelectList, with useAskPrompt hook
- Escape cancels from both modes, returning error status (red)
- Add context.ask() to ToolContext for interactive user questions
- Extract shared mockToolContext test utility, replacing 7 duplicates

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(test): add default MSW handler for Ollama context window requests

setupMsw() now accepts default handlers that persist across test resets.
Add ollamaShowHandler to all chat test suites to silence unhandled
request errors from fetchContextWindow on Chat mount.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(security): replace execSync with execFileSync to prevent shell injection

JSON.stringify does not escape shell metacharacters like $() and backticks
inside double-quoted strings. Since tool inputs come from the LLM, this was
exploitable via prompt injection. execFileSync bypasses the shell entirely,
eliminating the vulnerability by construction.

* fix(chat): prevent duplicate interrupted messages on config reload

The completion effect re-fired when config changes caused
completion.send to get a new reference, re-processing the stale
"aborted" state. A handledStateRef guard now skips already-processed
terminal states. Adds regression tests for abort + config reload and
abort + new message flows.

* feat(chat): nudge LLM on empty responses with user-visible status

When the LLM returns no content and no tool calls, re-send with a
nudge prompt (up to 3 retries). Each retry appends a dimmed info
message so the user knows what's happening. If all retries are
exhausted, a terminal info message tells the user to try again.

* fix(chat): show full diff output and improve truncation message

Diff output was truncated to 12 lines, hiding changes from the user
before approval. Diffs are now shown in full since they render in
Ink's Static area. Plain tool output truncation message now shows the
hidden line count (e.g. "…[5 more lines]").

* feat(context): add token-aware context window truncation

Uses js-tiktoken (cl100k_base) to count message tokens and drops
oldest non-system messages when the conversation exceeds the context
window budget. Truncation runs inside useCompletion.send() so all
send paths are covered. Reserves 5% headroom for tokenizer variance
and 4096 tokens for the model response.

* feat(tools): add run_command tool with streaming output

Shell command execution tool with allowed-commands auto-approval,
compound command detection, configurable timeout, and live output
streaming via onProgress. Extends ToolContext with allowedCommands
and onProgress callback. Adds LiveToolOutput component showing
the last N lines during execution.

* fix(chat): stabilize provider reference and guard terminal clears

Memoize the provider lookup to prevent .find() from creating a new
object each render, which cascaded into useCompletion recreating its
send callback and re-triggering the completion effect. Guard terminal
clear escape sequences with isTTY so they don't fire during tests.

* feat(tools): add web_search tool with Tavily API integration

Tavily-backed web search tool returning up to 5 results with AI
answer summary. API key resolved from config with TAVILY_API_KEY
env var fallback. Adds webSearchApiKey to ToolContext and wires
it through from config in chat.tsx.

* feat(prompt): include current date and time in system prompt

* feat(skills): add skill types and loader

Introduce SkillDefinition type and loader that reads SKILL.md files
from global (~/.tomo/skills/) and local (./.tomo/skills/) directories,
parsing YAML frontmatter for name and description.

Fix mock-fs listDir to return subdirectory names matching real
readdirSync behavior.

* feat(skills): add skill registry and input detection

SkillRegistry stores skills keyed by name+source, with local priority
on lookup. isSkill() detects // prefix for skill invocations.

* fix(config): stabilize reload callback to prevent infinite re-render

Wrap ConfigProvider's reload function in useCallback so consumers
depending on it in useEffect don't trigger an infinite update loop.

* feat(chat): extend autocomplete to support skill invocation via //

Add skill autocomplete mode triggered by // prefix, alongside the
existing / command autocomplete. AutocompleteItem gains key and tag
fields to support duplicate names across sources and (local) labels.

* refactor(skill): extract isSkill detection to utils and enhance autocomplete

Moved isSkill function from src/skills/is-skill.ts to src/skills/utils.ts with ParsedSkill interface and parseSkillInput helper. Enhanced autocomplete to support skill invocation via // prefix. Updated chat components to integrate skill detection and tool execution. Refactored skill registration and loading.

* feat(skill-sets): add git operations for skill set sources

Clone, pull, and remove skill set repos at ~/.tomo/skill-sets/ using
SHA256-based slugs for stable directory names. Shallow clone for
minimal disk usage.

* feat(skill-sets): add discovery and loading for skill sets

Discover skill sets via tomo-skills.json manifests in cloned repos.
Load skills from enabled sets namespaced as setName:skillName.

* feat(skill-sets): add config updaters for skill set sources

Add addSkillSetSource, removeSkillSetSource, and
updateSkillSetEnabledSets for managing skill set sources and
their enabled sets in global config.

* feat(skill-sets): add settings UI for managing skill set sources

Source list with add/remove/edit URLs, options sub-screen with
toggle list of discovered sets and update (pull) action. Clone on
add, remove clone on delete. Wire into settings routing.

* feat(skill-sets): wire enabled skill set skills into app registry

Load skills from enabled skill sets at startup, registered before
global/local skills so they have lowest priority. Skills appear in
// autocomplete as setName:skillName.

* fix(skill-sets): clone before persisting config and harden startup

Clone skill set sources before saving to config so failed clones don't
leave orphaned entries. Wrap skill set discovery in try/catch at startup
to prevent a broken source from crashing the app. Use bare `git pull`
instead of hardcoding `origin main` so repos with non-main default
branches work correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(tools): add skill tool for model-invoked skill lookups

Factory-based tool that closes over the skill registry so the model can
invoke skills via tool calls. Bakes available skill names into the tool
description and returns helpful errors for unknown skills.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(chat): preserve usage data across stream interruptions

Stop clearing usage on new send() calls so /context still reports the
last known token count when a stream is aborted before the final SSE
chunk arrives. Only update usage when the stream actually delivers new
data.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(images): add clipboard reading, message type, and provider conversion

macOS clipboard access via osascript with two strategies: file reference
(Finder copies) and raw image data (screenshots). Add optional images
field to UserMessage and convert to ContentPart[] when building provider
messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(images): add image paste, detection, and nav to chat input

Ctrl+V reads clipboard images via osascript. Image file paths in typed
text are auto-detected and extracted. Down arrow at end of input enters
image nav mode for browsing and removing attachments. onMessage now
passes images alongside the text.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(images): wire image support end-to-end

Images flow from chat input through to provider messages and session
persistence. Chat list shows image badges on user messages. History
recall preserves attached images so users can edit text and resubmit
without re-pasting.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(images): clear images on escape and highlight during confirmation

Double-escape now clears attached images along with text. Escape
pending triggers when only images are attached. Image tags highlight
in yellow inverse during escape confirmation to match the text style.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor(ui): trim instruction bars to contextual hints only

Drop obvious instructions (up/down navigate, enter submit, left/right
select) across all screens. Chat input esc is hidden by default and
only appears as "interrupt" when streaming or "clear" during
esc-pending. Editable list enter is contextual: "save" normally,
"remove" when item text is cleared. Dimmed removal hint added above
editable lists.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(ui): add prompt queue, structured confirm context, and system prompt enforcement

Replace single-slot confirm/ask with a FIFO prompt queue to support
concurrent tool executions. Extend ConfirmOptions with label/detail
fields rendered inside the bordered approval prompt. All tools now pass
structured context (e.g. "Run command?" + the command string).

Overhaul system prompt with enforced CRITICAL directives: parallel tool
calls, read-before-write, dedicated tool usage, and compound command
splitting — each with WRONG/CORRECT examples that dramatically improved
model tool use compliance.

* refactor(tools): parallel execution with paired output and tail display

Execute tool calls in parallel via Promise.all with per-tool scoped
onProgress for multi-slot live output. Return interleaved [call, result]
pairs so each tool call renders next to its output in the chat list.

Dynamic area shows tool call headers paired with their live streaming
output during execution, then batch-hoists everything to Static when
the batch completes. Provider messages merge interleaved display format
back into a single assistant turn for the API.

Static tool output now tails (last N lines) instead of truncating
(first N lines) — test results and build errors at the end of output
are more useful than opening boilerplate.

* feat(agents): add sub-agent tool with completion loop and streaming output

Introduce the agent tool for spawning autonomous sub-agents with their
own chat completion loops. Sub-agents stream content to the parent's
live output slot via onProgress/onContent, showing real-time progress
in the dynamic area with yellow-hued shimmer loading indicators.

Includes concurrency semaphore, configurable timeout, depth-based tool
scoping, and confirm wrapping that injects the agent name into approval
prompts. Sub-agent system prompt enforces parallel tool calls and
provides exploration strategy guidance.

* feat(agents): stream tool invocations and keep shimmer on live headers

* feat(tools): hoist completed tools to static individually

* feat(mcp): add settings screen for managing MCP server connections

Adds the MCP Servers settings screen, replacing the placeholder. Backed
by a new KvEditor primitive and a kv field type on Form for editing env
vars and HTTP headers. Schema simplified — kvEntrySchema removed since
sensitive masking offered no protection over plaintext config. The
runtime client/manager comes in a follow-up.

* feat(mcp): add stdio runtime with lazy background connect

Connects to configured stdio MCP servers in the background when the
chat mounts, registers discovered tools into the shared registry as
each server comes up, and surfaces connection failures as inline chat
error messages. Sub-agents auto-inherit any tool namespaced mcp__*
regardless of the agents allowlist. Uses the official SDK for the
transport layer and a dependency-injectable client factory on the
manager so lifecycle and error paths can be tested without real
subprocesses.

* style(mcp): drop unused biome-ignore suppressions

The ignore comments referenced a rule name that does not exist, so
biome flagged them as ineffective. Replaced with plain comments
explaining why the tests throw non-Error values.

* feat(mcp): collapse command and args into a single command field

The separate Command and Args form fields invited the common footgun
of typing the whole command line into Command and leaving Args empty —
which then spawns a binary literally named "node server.mjs" and fails
with ENOENT. Now the form has one Command field that holds the full
line and splits on whitespace at save time.

joinCommand/splitCommand are exported and unit-tested so the parsing
edge cases (empty, whitespace-only, runs of whitespace) have direct
coverage.

* refactor: remove all non-null assertions and clean up unused imports

Five pre-existing biome warnings are gone:

- chat.tsx: unused Text import removed
- chat.tsx: providerRef/modelRef refs dropped entirely; the effect now
  closes over provider and model directly (with deps updated to match)
  and handles the "provider cleared mid-stream" race by appending an
  error message
- chat.tsx: liveToolOutputs.get(...)! replaced with a local const plus
  an undefined check
- messages.ts: tool_calls!.push restructured — instead of reassigning
  an optional field on the message, we track the tool_calls array in a
  narrowed local and push into it directly, which also mutates the
  reference already attached to the message in the result list

The renderInk test helper now exposes reloadConfig(), which triggers
a config reload from the (mutated) mock filesystem. A new chat test
uses it to exercise the provider-cleared-mid-stream branch by holding
the completion stream open, rewriting the config file, reloading, then
releasing the stream.

* feat(ui): scrollable window for SelectList with maxVisible prop

Adds an optional maxVisible prop to SelectList. When the list exceeds
it, only a window of that many items renders, with "↑ N more" and
"↓ N more" indicators above and below as the cursor moves. Wrap-around
navigation jumps the window so the cursor stays visible.

/session now uses this with maxVisible=5 so long session histories
stop pushing the chat off the screen.

* feat(mcp): add streamable-HTTP transport

Extends createMcpClient to route on transport type. HTTP connections
use the SDK's StreamableHTTPClientTransport with connection.headers
mapped to requestInit.headers.

Extracted a shared wrapTransport helper so the stdio and http
factories share a single protocol-layer implementation — all the
Client.listTools / callTool / disconnect logic lives in one place.

No changes needed to the manager, adapter, useMcp hook, settings
screen, or sub-agent integration — they only know the generic
McpClient interface, so the new transport plugs in transparently.

Tests spawn the real mock-mcps/http.mjs via beforeAll/afterAll,
waiting for the "listening" stdout line before running. Covers
connect, listTools, callTool, argument passing, custom headers,
clean disconnect, and a full manager → adapter → Tool.execute
end-to-end round-trip through get_weather.

* feat(chat): open conversation in system pager on tab

Pressing Tab from the chat input renders the current conversation
to ANSI text and pipes it to the system pager (less by default).
PAGER env var is respected against an allowlist of less, more,
most, bat, cat. Tab is gated to no-op when no messages exist or
when the autocomplete list is open.

* feat(commands): add /help command listing commands and tips

Auto-generates a help screen from the command registry with four
sections: Commands, Skills, Images, Tips. Uses a factory pattern
so the command can close over its own registry, and is registered
last so it appears in its own listing.

* docs: rewrite README and CONTRIBUTING for new src layout

Both files are rewritten from scratch to match the current code.
README fixes the slash command list, config schema, MCP and skill
set shapes, and adds the Tab pager and /help command. CONTRIBUTING
documents the real feature-grouped structure, the actual coding
rules, and the verified CI / lefthook setup.

* chore: remove old reference impl, migration plan, and CLAUDE.md

The major-refactor is complete. Drops the old/ reference
implementation, repository-restructure.md (the migration plan),
and CLAUDE.md (rules now live in CONTRIBUTING.md as the single
source of truth for human and assistant contributors).

* docs: clean up CLAUDE.md and sync to .tomo/tomo.md

Drops the repository-restructure.md and old/ references (refactor
is done), the non-existent src/hooks/ rule, and the integration
tests subsection (src/__tests__/integration/ does not exist).

* fix(mcp): add .js extension to SDK subpath imports

esbuild's resolver requires an explicit .js extension for the
StdioClientTransport and StreamableHTTPClientTransport imports
because the SDK's package.json exports map uses a literal
"./*" → "./dist/esm/*" rule. tsx and tsc with bundler resolution
both accept the extensionless path, so this only failed under
`pnpm build`. Clarifies the no-.js-extensions rule in the docs
to scope it to internal/relative imports.

* fix(skill-sets): make git tests resilient to default branch config

Both cloneSource and pullSource setup blocks now pass
--initial-branch=main to git init explicitly. Previously the bare
repo's HEAD pointed to whatever git's init.defaultBranch was set
to (master on the CI runner), so clones came back with a dangling
HEAD and no checkout. The build failure had been masking this
since the tests never got a chance to run on CI.

---------

Co-authored-by: Lee Cheneler <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant