mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
feat(opencode): show the model an opencode session runs in its tile and split headers
An opencode tile showed only the session name, while Claude Code, codex and DeepSeek tiles add `· <model>`: opencode declared no `modelDetect`, so its screen was never read for a model and it is launched without a model param. opencode draws the model on its composer's agent row, directly above the box's bottom edge: `┃ Build Big Pickle OpenCode Zen`. Read from its own 1.3.0 source, the row is the agent, the model's name, the provider's name and `· <variant>` when the model has one, and only colour tells model from provider. So the field is all of it, exactly what opencode itself shows (the owner's choice over a short id that only appears after the first reply). - The pattern anchors on that row sitting directly above the `╹` edge, ends the field at a double space (where the 200-column layout's sidebar shares the row), skips the `No provider selected` placeholder, and takes the LAST such row in the window through a lookahead, so a composer-shaped row the agent prints higher up can never stand in for it. A test with a forged pair inside the window fails without the lookahead. - It reads 8 rows: the home screen puts up to five rows of opencode's own chrome under the composer (key hints, a tip, the cwd/version row). The schema's `screenLines` bound goes from 4 to 8, the reader's own cap; the comment there records why a taller window is only safe with such a pattern. - A permission prompt or shell mode hides the row; the last model is kept. Measured against every captured opencode 1.3.0 frame (home screen and in session, 40/60/120/200 columns, mid-turn and at rest, permission prompt): the model was read everywhere it is drawn and nowhere else. Live on an isolated instance from this branch, a restored opencode session published `displayModel: Big Pickle OpenCode Zen` (source: screen) and its tile header rendered `oc-home · Big Pickle OpenCode Zen`. The owner's own home-screen pane on the 1.36.0 beta reads the same. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,7 +63,7 @@ Five capability fields carry a regular expression an override file can set: `dis
|
||||
|
||||
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
||||
|
||||
`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 4, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?(<model>)`, three rows), and codex's ` <model> <effort> · ` footer on its last row. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the next field: the reasoning effort (` medium · <cwd>`), the session mode, or the folder name. So a captured field is not taken when it is one of the CLI's declared `modelDetect.rejectWords` (single tokens, compared ignoring case; dsh lists every effort id its adapters offer and the shipped mode ids) or the session's own working-directory basename (the shared reader's rule, for every CLI). Anything else the pattern captures is the model, so the official `deepseek-chat` / `deepseek-reasoner` ids are read.
|
||||
`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 8, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?(<model>)`, three rows), codex's ` <model> <effort> · ` footer on its last row, and opencode's composer agent row (`┃ Build <model> <provider>`, directly above the box's `╹` edge, eight rows because its home screen puts up to five rows of its own chrome below it). opencode's field is the model AND the provider, since only colour separates them on that row; the pattern takes the LAST such row in the window, so a composer-shaped row the agent prints higher up cannot stand in for it. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the next field: the reasoning effort (` medium · <cwd>`), the session mode, or the folder name. So a captured field is not taken when it is one of the CLI's declared `modelDetect.rejectWords` (single tokens, compared ignoring case; dsh lists every effort id its adapters offer and the shipped mode ids) or the session's own working-directory basename (the shared reader's rule, for every CLI). Anything else the pattern captures is the model, so the official `deepseek-chat` / `deepseek-reasoner` ids are read.
|
||||
|
||||
`modelDetect.configResolver` names a READER in `src/model-config-resolvers.ts` (a name, never code in config, like a launcher profile) that resolves the model the CLI's own config pins for one session, for while its screen names none (the `config` source of `displayModel`, ranked below any report from the running CLI). It runs at every pane start, attach and relaunch, with the session's own launch config and env, and must be read-only, bounded (probe before read, no synchronous filesystem call) and return the model id alone. The one stock reader, `deepseek-route` (`src/deepseek-route-config.ts`), resolves dsh-TUI's route the way dsh composes it for the session's profile under the session's `DSH_HOME`: the last of `profiles/<profile>/cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying `config` for the `dsh-tui` row counts, and only when it names both `provider` and `model`. Anything in doubt answers nothing: a half-pinned route, a profile without dsh-TUI, an unreadable, oversized or symlinked-out layer, a file beyond its narrow YAML subset.
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Each tile has a small header: `● [logo] name · model ......... ⋯ ⤢ ×`
|
||||
| `●` | The session's state: working, idle, waiting on you, needs you (red, and the tile's border pulses), error, ended. Hover the header for how long. |
|
||||
| logo | Which agent runs in the tile (Claude Code, Codex, DeepSeek, Shell, ...). Hover it for the agent and the model by name. |
|
||||
| name | Double-click to rename the session. |
|
||||
| model | The model the session runs, when Codeman knows it: what the agent itself reports (it follows a `/model` switch), else the model its own config pins (DeepSeek's route, shown "from config"), else the model it was started with. Nothing when unknown. |
|
||||
| model | The model the session runs, when Codeman knows it: what the agent itself reports (it follows a `/model` switch), else the model its own config pins (DeepSeek's route, shown "from config"), else the model it was started with. Nothing when unknown. OpenCode shows the model and its provider together (`Big Pickle OpenCode Zen`), exactly as its own composer does. |
|
||||
| `⋯` | The session menu: options, open in a new window, close the session. |
|
||||
| `⤢` | Zoom: the tile fills the grid; press it again (or `Alt+Shift+Enter`) to get the grid back. |
|
||||
| `×` | Remove the tile. The session keeps running; close it from `⋯` if you want it gone. |
|
||||
|
||||
@@ -385,8 +385,10 @@ const capabilitiesSchema = z
|
||||
)
|
||||
.optional(),
|
||||
// Bounded hard, like watchingLines: every row it adds is one more row the agent
|
||||
// itself may be able to write.
|
||||
screenLines: z.number().int().min(1).max(4).optional(),
|
||||
// itself may be able to write. 8 is the reader's own cap (readScreenModel); a
|
||||
// window taller than the CLI's footer needs a pattern only that CLI's chrome can
|
||||
// satisfy at its position, as opencode's does by taking the LAST composer row.
|
||||
screenLines: z.number().int().min(1).max(8).optional(),
|
||||
// Single tokens, bounded: each is compared against one captured field.
|
||||
rejectWords: z.array(z.string().min(1).max(40).regex(/^\S+$/)).max(32).optional(),
|
||||
// A NAMED reader (src/model-config-resolvers.ts), never code in config.
|
||||
|
||||
@@ -514,6 +514,22 @@ const OPENCODE: CliEntry = {
|
||||
promptGlyph: '┃',
|
||||
workingLine: '[⬝■]{8}',
|
||||
},
|
||||
// The composer's agent row, measured on live opencode 1.3.0 panes (home screen and in
|
||||
// session, at 40, 60, 120 and 200 columns, 2026-10-09): `┃ Build Big Pickle OpenCode
|
||||
// Zen`, directly above the box's bottom edge `╹▀▀▀`. opencode renders it as the agent,
|
||||
// then the model's name, then the provider's name (then `· <variant>` when the model
|
||||
// has one), and only colour tells model from provider, so the field is all of it: what
|
||||
// opencode itself shows, owner's choice. A double space ends it, which is where the
|
||||
// 200-column layout's sidebar shares the row. The lookahead takes the LAST such row in
|
||||
// the window, so nothing the agent prints higher up can stand in for it; below the
|
||||
// composer there is only opencode's own chrome (key hints, a tip, the cwd/version
|
||||
// row), which is why the window can be 8 rows: the home screen puts up to 5 of those
|
||||
// rows under it. A permission prompt or shell mode hides the row, and the last model
|
||||
// is kept. `No provider ` is opencode's placeholder before a provider is connected.
|
||||
modelDetect: {
|
||||
screenLine: String.raw`┃ {2}[^\s·]+ {2}(?!No provider )([^ \n](?:[^ \n]| (?! ))*)(?: {2}.*)?\n *╹(?![\s\S]*\n *╹)`,
|
||||
screenLines: 8,
|
||||
},
|
||||
// opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`.
|
||||
mcpConfig: {
|
||||
path: '.config/opencode/opencode.json',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* The pane fixtures are verbatim `capture-pane -p` rows (trailing blanks trimmed) from
|
||||
* live panes on 2026-10-07: dsh-TUI 0.10.0-beta.1 on the owner's qwen route, and codex
|
||||
* 0.147.0. The codex 0.154.0 footer is the one `session-watching.test.ts` pins.
|
||||
* The opencode 1.3.0 composer rows are from 2026-10-09 captures.
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -302,6 +303,133 @@ describe('readScreenModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* opencode 1.3.0's composer agent row, verbatim (trailing blanks trimmed, bars shortened):
|
||||
* the owner's home screen on the 1.36.0 beta, one with a tip, an in-session pane at 200
|
||||
* columns whose sidebar shares the row, and a 40-column pane mid-turn.
|
||||
*/
|
||||
const OC_EDGE = '╹' + '▀'.repeat(56);
|
||||
const OC_HOME = [
|
||||
' ┃',
|
||||
' ┃ Ask anything... "Fix a TODO in the codebase"',
|
||||
' ┃',
|
||||
' ┃ Build Big Pickle OpenCode Zen',
|
||||
` ${OC_EDGE}`,
|
||||
' ctrl+t variants tab agents ctrl+p commands',
|
||||
' ~/codeman-cases/testcase 1.3.0',
|
||||
'',
|
||||
].join('\n');
|
||||
const OC_HOME_TIP = [
|
||||
' ┃ Ask anything... "Fix a TODO in the codebase"',
|
||||
' ┃',
|
||||
' ┃ Build Big Pickle OpenCode Zen',
|
||||
` ${OC_EDGE}`,
|
||||
' ctrl+t variants tab agents ctrl+p commands',
|
||||
' ● Tip Use {env:VAR_NAME} syntax to reference environment variables in',
|
||||
' /tmp/claude-1000/-home-arkon-default-claudeman/6dec47be/scratchpad/oc-repro 1.3.0',
|
||||
'',
|
||||
].join('\n');
|
||||
const OC_WIDE = [
|
||||
' ▣ Build · qwen3.8-27b · 11.9s' + ' '.repeat(40) + '[•] summarize',
|
||||
' ┃' + ' '.repeat(157) + '/tmp/claude-1000/-home-arkon-default-',
|
||||
' ┃' + ' '.repeat(157) + 'claudeman/6dec47be-0153-4e13-a229-',
|
||||
' ┃ Build qwen3.8-27b Qwen 5090' + ' '.repeat(127) + 'fc335c2c76c8/scratchpad/oc-turn',
|
||||
` ${OC_EDGE}`,
|
||||
' '.repeat(129) + 'tab agents ctrl+p commands • OpenCode 1.3.0',
|
||||
'',
|
||||
].join('\n');
|
||||
const OC_PHONE_MID = [
|
||||
' ┃',
|
||||
' ┃ Build qwen3.8-27b Qwen 5090',
|
||||
' ╹' + '▀'.repeat(35),
|
||||
' ⬝⬝⬝⬝⬝⬝⬝⬝ esc tab ctrl+p',
|
||||
' interragents commands',
|
||||
' upt',
|
||||
'',
|
||||
].join('\n');
|
||||
const OC = compileVersionRegex(detectOf('opencode').screenLine)!;
|
||||
const OC_ROWS = detectOf('opencode').screenLines;
|
||||
|
||||
describe("opencode's composer agent row", () => {
|
||||
it('compiles through compileVersionRegex() with exactly one capture group', () => {
|
||||
expect(OC).not.toBeNull();
|
||||
expect(countCaptureGroups(detectOf('opencode').screenLine!)).toBe(1);
|
||||
});
|
||||
|
||||
it('reads the model and provider opencode shows, at every width and on the home screen', () => {
|
||||
expect(readScreenModel(OC_HOME, OC, OC_ROWS)).toBe('Big Pickle OpenCode Zen');
|
||||
expect(readScreenModel(OC_HOME_TIP, OC, OC_ROWS)).toBe('Big Pickle OpenCode Zen');
|
||||
// The 200-column sidebar shares the row after a run of spaces, and stays out of it.
|
||||
expect(readScreenModel(OC_WIDE, OC, OC_ROWS)).toBe('qwen3.8-27b Qwen 5090');
|
||||
expect(readScreenModel(OC_PHONE_MID, OC, OC_ROWS)).toBe('qwen3.8-27b Qwen 5090');
|
||||
});
|
||||
|
||||
it('keeps the variant opencode shows after the provider', () => {
|
||||
const variant = OC_HOME.replace('Big Pickle OpenCode Zen', 'Claude Sonnet 4.5 Anthropic · high');
|
||||
expect(readScreenModel(variant, OC, OC_ROWS)).toBe('Claude Sonnet 4.5 Anthropic · high');
|
||||
});
|
||||
|
||||
it('says nothing where the row is hidden or holds no model', () => {
|
||||
// A permission prompt replaces the composer: no agent row above a `╹` edge.
|
||||
const permission = [
|
||||
' ┃ △ Permission required',
|
||||
' ┃ $ echo permission-check',
|
||||
' ┃ Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm',
|
||||
' ┃',
|
||||
'',
|
||||
].join('\n');
|
||||
expect(readScreenModel(permission, OC, OC_ROWS)).toBeUndefined();
|
||||
// Shell mode draws `Shell` and no model; before a provider is connected, a placeholder.
|
||||
expect(readScreenModel(OC_HOME.replace('Build Big Pickle OpenCode Zen', 'Shell'), OC, OC_ROWS)).toBeUndefined();
|
||||
const none = OC_HOME.replace('Big Pickle OpenCode Zen', 'No provider selected Connect a provider');
|
||||
expect(readScreenModel(none, OC, OC_ROWS)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never takes a row the agent printed above the composer', () => {
|
||||
// An answer that draws a composer-shaped row and an edge under it: the real composer
|
||||
// is the LAST such pair, and only opencode's own chrome sits below that.
|
||||
const forged = [
|
||||
' ┃ Build evil-model Evil',
|
||||
` ${OC_EDGE}`,
|
||||
' ┃',
|
||||
' ┃ Build Big Pickle OpenCode Zen',
|
||||
` ${OC_EDGE}`,
|
||||
' ctrl+t variants tab agents ctrl+p commands',
|
||||
' ~/codeman-cases/testcase 1.3.0',
|
||||
'',
|
||||
].join('\n');
|
||||
// Both pairs sit inside the window, so this is the lookahead's doing, not the window's.
|
||||
expect(forged.split('\n').filter(Boolean).length).toBeLessThanOrEqual(OC_ROWS!);
|
||||
expect(readScreenModel(forged, OC, OC_ROWS)).toBe('Big Pickle OpenCode Zen');
|
||||
});
|
||||
|
||||
it('publishes the model through a session once the idle check reads the pane', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const mux = { isAvailable: () => true, capturePaneText: () => OC_HOME } as unknown as NonNullable<
|
||||
ConstructorParameters<typeof Session>[0]
|
||||
>['mux'];
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'opencode',
|
||||
mux,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
|
||||
} as ConstructorParameters<typeof Session>[0]);
|
||||
const internals = session as unknown as {
|
||||
_handleTerminalOutput(data: string): void;
|
||||
_detectInteractiveActivity(data: string): void;
|
||||
};
|
||||
const frame = '\x1b[37;3H┃ Build Big Pickle OpenCode Zen';
|
||||
internals._handleTerminalOutput(frame);
|
||||
internals._detectInteractiveActivity(frame);
|
||||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 3000);
|
||||
expect(session.toState().displayModel).toEqual({ model: 'Big Pickle OpenCode Zen', source: 'screen' });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeModelName', () => {
|
||||
it('keeps markup as text: sanitizing is not escaping, the browser renders text', () => {
|
||||
expect(sanitizeModelName('<img src=x onerror=alert(1)>')).toBe('<img src=x onerror=alert(1)>');
|
||||
|
||||
Reference in New Issue
Block a user