Skip to content

refactor: rebuild src/ as feature-grouped layout#270

Merged
LeeCheneler merged 98 commits intomainfrom
major-refactor
Apr 9, 2026
Merged

refactor: rebuild src/ as feature-grouped layout#270
LeeCheneler merged 98 commits intomainfrom
major-refactor

Conversation

@LeeCheneler
Copy link
Copy Markdown
Owner

Summary

Rebuilds the entire src/ tree as a feature-grouped codebase, replacing the previous type-grouped layout. 96 commits, 318 files changed (+31,684 / -25,490). The refactor was developed on a long-running branch with the old code preserved under old/ until the new code reached parity, then old/ was deleted.

GitHub Issue

N/A

What Changed

Architecture. Code is now grouped by feature rather than type. Each feature owns its components, hooks, and logic in a single directory (chat/, settings/, session/, model/, mcp/, skills/, skill-sets/, tools/, etc.). Shared stateless primitives live in ui/. Feature-specific hooks are co-located in the same file as their component rather than living in a separate hooks/ directory.

Component patterns. Business logic moved into hooks; components are pure rendering. Menus are self-sufficient — they own their state and step navigation, parents receive a result. Indentation is handled via <Indent> / <Box paddingLeft> instead of hardcoded space strings.

Tools. All built-in tools (read, write, edit, glob, grep, run command, ask, skill, agent, web search) ported with full coverage. New tool execution flow with confirm prompts, diff rendering, and per-tool live progress in the chat list.

MCP. New MCP client with stdio and streamable-HTTP transports, lazy background connection, and a /settings → MCP Servers UI. Config schema moved from mcpServers to mcp.connections and supports \${ENV_VAR} interpolation.

Settings UI. New /settings menu replaces the old multi-command setup flow. Manages providers, permissions, allowed commands, tools, MCP servers, and skill sets — everything previously requiring ~/.tomo/config.yaml edits.

Config schema. Rewritten with zod. Permissions split into cwdReadFile / cwdWriteFile / globalReadFile / globalWriteFile. Skill sets nested under skillSets.sources[].enabledSets. Agents config exposes maxDepth, maxConcurrent, maxTimeoutSeconds, and a per-agent tool allowlist.

New end-user features

  • `Tab` from the chat input opens the conversation in the system pager (`less` by default, `PAGER` env var honored against an allowlist)
  • `/help` slash command auto-generated from the registered commands
  • Sub-agent runtime with concurrency, depth limits, and progress indicators
  • Skills via `//skill-name` and skill sets via git repos
  • Image paste via `Cmd+V` (file paths) and `Ctrl+V` (clipboard)
  • Session JSONL persistence with browser via `/session`

Tests. 1399 tests, 100% coverage. Test utilities under `src/test-utils/` (`renderInk`, `keys`, MSW helpers). Tests are colocated with source.

Docs. README and CONTRIBUTING fully rewritten to match the new code. CLAUDE.md cleaned up and synced into `.tomo/tomo.md` so the same rules apply when dogfooding tomo.

Notes for Reviewers

  • Merge strategy: this should be merged with a merge commit (not squash). 96 atomic commits is a lot, but they each leave the codebase in a working state and the history is worth preserving.
  • `old/` is gone: the previous reference implementation lived under `old/` during the refactor and was deleted in `a72b125` after parity was reached.
  • CI is green: all build, lint, format, typecheck, and test (with 100% coverage) checks pass on this branch.

LeeCheneler and others added 30 commits March 30, 2026 19:50
- 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]>
- 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
- 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
* 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: 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(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]>
…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]>
…#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]>
…istory (#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 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 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]>
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]>
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]>
Reusable keyboard-navigable menu component with cursor indicator,
clamped selection, and configurable color. Settings now delegates
menu rendering and input handling to NavigationMenu.
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]>
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]>
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]>
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]>
Replace all stdin.write(" ") calls with stdin.write(keys.space) for
consistency with other key constants in the test utility.
…n 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".
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.
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.
LeeCheneler and others added 25 commits April 7, 2026 20:22
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]>
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]>
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]>
…rsion

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]>
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]>
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]>
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]>
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]>
…ompt 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.
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.
…utput

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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
@github-actions github-actions Bot added the refactor Code refactoring label Apr 9, 2026
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.
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.
@LeeCheneler LeeCheneler merged commit 8eea668 into main Apr 9, 2026
4 checks passed
@LeeCheneler LeeCheneler deleted the major-refactor branch April 9, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

refactor Code refactoring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant