Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
All notable changes to `codesema` (the npm package in `packages/cli`) are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org).

## [0.16.0] - unreleased

### Added

- **The three global brain-loop settings now have a panel in the local web UI, not only `codesema config` or a hand-edited config file.** `GET /api/settings` reports `brainAutoMerge`, `mergeStrategy` and `maxTaskTurns` as both their resolved, effective value (`config.ts`'s own `resolveBrainAutoMerge`/`resolveMergeSettings`/`resolveMaxTaskTurns`) and their raw configured one; `PUT /api/settings` accepts a partial update, validates every field against the same bounds the config file parser itself enforces (an integer turn budget between 1 and 500, one of the three forge merge strategies), refuses any other key, and never writes a partial update when one field fails validation. The repo settings screen gains a Brain integration section, a toggle, a strategy selector and a turn-budget field, saving through the existing global config token and `saveGlobalConfig`, the same function the CLI wizard already writes through. `mergeStrategy` also joins the `codesema config` menu itself, alongside `brainAutoMerge` and the turn budget, with its own "not set (forge default)" state shown and selectable.

## [0.15.0] - 2026-08-26

### Added
Expand Down Expand Up @@ -74,6 +80,8 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S

- **The workspace navigation is three zones now, not a sidebar tree, a status queue and a pinnable deck.** A 215px category rail — collapsible to a strip of icons — switches between **Conversation** and **Repository**; next to it, a resizable list column shows whatever that category holds; past both, one content zone renders exactly one thing at a time — a conversation, a draft, a repository, or a review — never several side by side. Conversations are no longer sorted into four visible bands: `groupConversationsByProject` now groups them by project first (display-name order), and what used to be the section — **Needs you**, **In progress**, **Ready to ship**, **Done** — is only the sort key inside each group (`SECTION_RANK`, `compareByActivity` breaking ties), so a project's own conversations sit together instead of being scattered across four piles. The header's search field is gone from the header: each list searches its own corpus now, conversations or repositories, and a box sitting above both would be a second search reaching for a third, overlapping thing — `⌘K` still focuses it, wherever it currently lives.

- **The README is readable again, and it says what the code does.** It had grown to 95 KB in which design rationale, decision numbers and internal invariants were folded into the user documentation. It is rewritten as a reference for the person installing the CLI: what each command does, the configuration keys with their real defaults and their global-only scope, the files written on disk, the exit codes. Three implementation notes with no other home (subprocess environments, forge issue hierarchy, cycle labels) moved to `docs/internals.md`. One claim was stale and is corrected: `claude` and `opencode` are both cageable, not `claude` alone.

### Removed

- **The standalone review page no longer browses the forge.** `MrSidebar.vue`, `BranchSidebar.vue` and `MrDetailPanel.vue` are gone, and the twenty-three message keys they alone owned with them. What that page does is read the one review its process is serving — what `codesema review` opens, and what CI keeps — and the merge request and branch browsing it used to carry is what the workspace now does better, across every project instead of one. `components/FocusView.vue` is renamed `ReviewFocusMode.vue`: it is the review's problems-first mode and had nothing to do with the `FocusView` navigation union it shared a name with.
Expand Down
556 changes: 167 additions & 389 deletions README.md

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions docs/internals.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codesema-tools",
"version": "0.15.0",
"version": "0.16.0",
"private": true,
"type": "module",
"workspaces": [
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codesema",
"version": "0.15.0",
"version": "0.16.0",
"description": "Local merge request review, step by step. Your AI agent reviews, codesema displays.",
"license": "MIT",
"author": "Hasan TASKIN",
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ terminal, offers to upgrade when a newer version exists. Set CODESEMA_NO_UPDATE_
'config.maxTurnsQuestion': 'How many turns may one task spend before replies are refused?',
'config.maxTurnsDefaultHint': 'default',
'config.maxTurnsSaved': 'turn budget {cap}: {path}',
'config.mergeStrategyEntry': 'Merge strategy',
'config.mergeStrategyUnset': 'not set (forge default)',
'config.mergeStrategyQuestion': 'Which strategy should the forge use to merge a task branch?',
'config.mergeStrategySaved': 'merge strategy {state}: {path}',
'config.back': 'Back',
'config.languageSaved': 'language saved: {path}',

Expand Down Expand Up @@ -769,6 +773,11 @@ CODESEMA_NO_UPDATE_CHECK=1 pour désactiver.
'Combien de tours une tâche peut-elle dépenser avant que les relances soient refusées ?',
'config.maxTurnsDefaultHint': 'défaut',
'config.maxTurnsSaved': 'budget de tours {cap} : {path}',
'config.mergeStrategyEntry': 'Stratégie de fusion',
'config.mergeStrategyUnset': 'non définie (défaut de la forge)',
'config.mergeStrategyQuestion':
'Quelle stratégie la forge doit-elle utiliser pour fusionner une branche de tâche ?',
'config.mergeStrategySaved': 'stratégie de fusion {state} : {path}',
'config.back': 'Retour',
'config.languageSaved': 'langue enregistrée : {path}',

Expand Down
83 changes: 83 additions & 0 deletions packages/cli/src/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,89 @@ describe('startServer', () => {
expect(JSON.parse(afterToggle.body)).toMatchObject({ syncAutoPush: true })
})

test('reports the effective brain settings with their resolved defaults', async () => {
const res = await rawRequest(port, '/api/settings')
expect(res.status).toBe(200)
expect(JSON.parse(res.body)).toEqual({
brainAutoMerge: { value: true },
mergeStrategy: {},
maxTaskTurns: { value: 30 },
})
})

test('rejects settings mutations without a valid config token', async () => {
const noToken = await rawRequest(port, '/api/settings', {
method: 'PUT',
body: '{"brainAutoMerge":false}',
})
expect(noToken.status).toBe(403)
const badToken = await rawRequest(port, '/api/settings', {
method: 'PUT',
headers: { 'x-codesema-config-token': 'wrong' },
body: '{"brainAutoMerge":false}',
})
expect(badToken.status).toBe(403)
})

test('validates every settings field and never writes a partial update', async () => {
const html = await rawRequest(port, '/')
const tokenMatch = /__CODESEMA_CONFIG_TOKEN__="([a-f0-9]{32})"/.exec(html.body)
expect(tokenMatch).not.toBeNull()
const token = tokenMatch![1]!

const rejections = [
'{"nope":true}',
'{"brainAutoMerge":"yes"}',
'{"mergeStrategy":"fast-forward"}',
'{"maxTaskTurns":0}',
'{"maxTaskTurns":501}',
'{"maxTaskTurns":1.5}',
'{"maxTaskTurns":"30"}',
]
for (const body of rejections) {
const res = await rawRequest(port, '/api/settings', {
method: 'PUT',
headers: { 'x-codesema-config-token': token },
body,
})
expect(res.status).toBe(400)
}

const stillDefault = await rawRequest(port, '/api/settings')
expect(JSON.parse(stillDefault.body)).toEqual({
brainAutoMerge: { value: true },
mergeStrategy: {},
maxTaskTurns: { value: 30 },
})

const written = await rawRequest(port, '/api/settings', {
method: 'PUT',
headers: { 'x-codesema-config-token': token },
body: JSON.stringify({ brainAutoMerge: false, mergeStrategy: 'squash', maxTaskTurns: 60 }),
})
expect(written.status).toBe(200)
expect(JSON.parse(written.body)).toEqual({
brainAutoMerge: { value: false, raw: false },
mergeStrategy: { value: 'squash', raw: 'squash' },
maxTaskTurns: { value: 60, raw: 60 },
})

const afterWrite = await rawRequest(port, '/api/settings')
expect(JSON.parse(afterWrite.body)).toEqual(JSON.parse(written.body))

const partial = await rawRequest(port, '/api/settings', {
method: 'PUT',
headers: { 'x-codesema-config-token': token },
body: '{"brainAutoMerge":true}',
})
expect(partial.status).toBe(200)
expect(JSON.parse(partial.body)).toEqual({
brainAutoMerge: { value: true, raw: true },
mergeStrategy: { value: 'squash', raw: 'squash' },
maxTaskTurns: { value: 60, raw: 60 },
})
})

test('reports the open MRs as unavailable when the repo has no remote', async () => {
const res = await rawRequest(port, '/api/mrs')
expect(res.status).toBe(200)
Expand Down
106 changes: 105 additions & 1 deletion packages/cli/src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@ import { fileURLToPath } from 'node:url'
import { brainErrorMessage, brainRemoteUrl, listTickets, type BrainError } from './brain-client.js'
import { startBrainDaemon, type BrainDaemonHandle } from './brain-daemon.js'
import { listLocalBranches, listWorktrees } from './branches.js'
import { loadGlobalConfig, saveGlobalConfig, type CodesemaConfig } from './config.js'
import {
isMergeStrategy,
loadGlobalConfig,
resolveBrainAutoMerge,
resolveMaxTaskTurns,
resolveMergeSettings,
saveGlobalConfig,
type CodesemaConfig,
type MergeStrategy,
} from './config.js'
import {
isTaskId,
sanitizeRecord,
Expand Down Expand Up @@ -639,6 +648,95 @@ function nextGlobalConfig(current: CodesemaConfig, picked: AgentSelection): Code
return next
}

type SettingsSnapshot = {
brainAutoMerge: { value: boolean; raw: boolean | undefined }
mergeStrategy: { value: MergeStrategy | undefined; raw: MergeStrategy | undefined }
maxTaskTurns: { value: number; raw: number | undefined }
}

function settingsSnapshot(config: CodesemaConfig): SettingsSnapshot {
const merge = resolveMergeSettings(config)
return {
brainAutoMerge: { value: resolveBrainAutoMerge(config), raw: config.brainAutoMerge },
mergeStrategy: { value: merge.strategy, raw: config.mergeStrategy },
maxTaskTurns: { value: resolveMaxTaskTurns(config), raw: config.maxTaskTurns },
}
}

function handleSettingsGet(res: ServerResponse): void {
return sendJson(res, 200, settingsSnapshot(loadGlobalConfig()))
}

const MAX_SETTINGS_BODY_BYTES = 1024
const MAX_TASK_TURNS = 500

type SettingsUpdate = Pick<CodesemaConfig, 'brainAutoMerge' | 'mergeStrategy' | 'maxTaskTurns'>
const SETTINGS_KEYS: ReadonlySet<string> = new Set([
'brainAutoMerge',
'mergeStrategy',
'maxTaskTurns',
])

/**
* PUT /api/settings writes the three GLOBAL-ONLY brain-loop settings
* (config.ts: brainAutoMerge, mergeStrategy, maxTaskTurns), so it needs the
* same per-server CSRF token as the other /api/config/* mutations. Every
* field is validated before ANY write happens, so a bad field never leaves
* the other, valid ones written and the invalid one silently skipped.
*/
async function handleSettingsUpdate(
req: IncomingMessage,
res: ServerResponse,
repoConfig: RepoConfigEndpoint,
): Promise<void> {
if (req.headers['x-codesema-config-token'] !== repoConfig.token) {
return sendText(res, 403, 'forbidden')
}
let body: unknown
try {
body = await readJsonBody(req, MAX_SETTINGS_BODY_BYTES)
} catch {
return sendText(res, 400, 'bad request')
}
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return sendText(res, 400, 'bad request')
}
const payload = body as Record<string, unknown>
const unknownKey = Object.keys(payload).find((key) => !SETTINGS_KEYS.has(key))
if (unknownKey) {
return sendJson(res, 400, { error: `unknown setting: ${unknownKey}` })
}
const update: SettingsUpdate = {}
if ('brainAutoMerge' in payload) {
if (typeof payload.brainAutoMerge !== 'boolean') {
return sendJson(res, 400, { error: 'brainAutoMerge must be a boolean' })
}
update.brainAutoMerge = payload.brainAutoMerge
}
if ('mergeStrategy' in payload) {
if (!isMergeStrategy(payload.mergeStrategy)) {
return sendJson(res, 400, { error: 'mergeStrategy must be one of merge, squash, rebase' })
}
update.mergeStrategy = payload.mergeStrategy
}
if ('maxTaskTurns' in payload) {
const turns = payload.maxTaskTurns
if (
typeof turns !== 'number' ||
!Number.isInteger(turns) ||
turns < 1 ||
turns > MAX_TASK_TURNS
) {
return sendJson(res, 400, {
error: `maxTaskTurns must be an integer between 1 and ${MAX_TASK_TURNS}`,
})
}
update.maxTaskTurns = turns
}
saveGlobalConfig({ ...loadGlobalConfig(), ...update })
return handleSettingsGet(res)
}

const MAX_TASK_BODY_BYTES = 64 * 1024
const MAX_AGENT_BODY_BYTES = TASK_AGENT_MAX + 1024

Expand Down Expand Up @@ -1469,6 +1567,9 @@ function createRequestHandler(handlerOpts: {
if (pathname === '/api/config/agent') {
return void handleConfigAgentUpdate(req, res, repoConfig, tasks)
}
if (pathname === '/api/settings') {
return void handleSettingsUpdate(req, res, repoConfig)
}
return sendText(res, 405, 'method not allowed')
}
if (req.method !== 'GET') {
Expand All @@ -1489,6 +1590,9 @@ function createRequestHandler(handlerOpts: {
if (pathname === '/api/config') {
return void handleConfigGet(res, cwd, tasks, listAgents)
}
if (pathname === '/api/settings') {
return handleSettingsGet(res)
}
if (
pathname === '/api/mrs' ||
pathname === '/api/issues' ||
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/wizard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,30 @@ import {
afterEach(() => setLanguage(null))

describe('describeConfigEntries', () => {
test('lists agent, language, auto-sync, brain auto-merge, turn budget then back, with current values as hints', () => {
test('lists agent, language, auto-sync, brain auto-merge, merge strategy, turn budget then back, with current values as hints', () => {
const entries = describeConfigEntries({
agent: 'claude -p --model opus',
language: 'fr',
syncAutoPush: true,
brainAutoMerge: false,
mergeStrategy: 'squash',
maxTaskTurns: 60,
})
expect(entries.map((entry) => entry.id)).toEqual([
'agent',
'language',
'autoSync',
'brainAutoMerge',
'mergeStrategy',
'maxTaskTurns',
'back',
])
expect(entries[0]?.hint).toBe('claude -p --model opus')
expect(entries[1]?.hint).toBe('Français')
expect(entries[2]?.hint).toBe(t('config.autoSyncOn'))
expect(entries[3]?.hint).toBe(t('config.brainAutoMergeOff'))
expect(entries[4]?.hint).toBe('60')
expect(entries[4]?.hint).toBe('squash')
expect(entries[5]?.hint).toBe('60')
})

test('falls back to explicit placeholders when nothing is configured', () => {
Expand All @@ -49,6 +52,8 @@ describe('describeConfigEntries', () => {
expect(entries[2]?.hint).toBe(t('config.autoSyncUnset'))
// Unlike the other three, absent resolves to ON (resolveBrainAutoMerge's own doctrine), never an "unset" placeholder.
expect(entries[3]?.hint).toBe(t('config.brainAutoMergeOn'))
// Like autoSync, absence IS its own state here (resolveMergeSettings, D13): no strategy is picked on the project's behalf.
expect(entries[4]?.hint).toBe(t('config.mergeStrategyUnset'))
})

test('a declined auto-sync opt-in shows as off', () => {
Expand Down
Loading
Loading