Compare commits

...

6 Commits

Author SHA1 Message Date
ethernet
5468daae87 feat(ci): add lint (warnings only) 2026-05-04 14:13:37 -04:00
ethernet
96f3f501f5 change: enable ruff/ty 2026-05-04 14:13:25 -04:00
asheriif
0ce1b9fe20 fix(tui): preserve prompt separator width (#19340)
* fix(tui): preserve prompt separator width

* fix(tui): align transcript height estimates with prompt width
2026-05-04 09:58:40 -07:00
brooklyn!
d9c090fe36 Merge pull request #19338 from asheriif/fix/tui-plugin-slash-exec-live
fix(tui): run plugin slash commands live
2026-05-04 09:57:45 -07:00
asheriif
21c7c9f0ca fix(tui): harden plugin slash exec errors 2026-05-04 09:07:37 +00:00
asheriif
7e780f4832 fix(tui): run plugin slash commands live 2026-05-03 19:42:16 +00:00
11 changed files with 567 additions and 21 deletions

151
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,151 @@
name: Lint (ruff + ty)
# Surface ruff and ty diagnostics as a diff vs the target branch.
# This check is advisory only ATM it always exits zero and never blocks merge.
# It posts a Markdown summary to the workflow run and, for pull requests,
# comments the same summary on the PR.
on:
push:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
- "website/**"
pull_request:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
- "website/**"
permissions:
contents: read
pull-requests: write # needed to post/update PR comments
concurrency:
group: lint-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-diff:
name: ruff + ty diff
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0 # need full history for merge-base + worktree
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Install ruff + ty
run: |
uv tool install ruff
uv tool install ty
- name: Determine base ref
id: base
run: |
# For PRs, diff against the merge base with the target branch.
# For pushes to main, diff against the previous commit on main.
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
BASE_REF="origin/${{ github.base_ref }}"
else
BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD)
BASE_REF="HEAD~1"
fi
echo "sha=${BASE_SHA}" >> "$GITHUB_OUTPUT"
echo "ref=${BASE_REF}" >> "$GITHUB_OUTPUT"
echo "Base SHA: ${BASE_SHA}"
echo "Base ref: ${BASE_REF}"
- name: Run ruff + ty on HEAD
run: |
mkdir -p .lint-reports/head
ruff check --output-format json --exit-zero \
> .lint-reports/head/ruff.json || true
ty check --output-format gitlab --exit-zero \
> .lint-reports/head/ty.json || true
echo "HEAD ruff: $(wc -c < .lint-reports/head/ruff.json) bytes"
echo "HEAD ty: $(wc -c < .lint-reports/head/ty.json) bytes"
- name: Run ruff + ty on base (via git worktree)
run: |
mkdir -p .lint-reports/base
# Use a worktree so we don't clobber the main checkout. If the basex
# SHA is identical to HEAD (e.g. first commit), skip and leave the
# base reports empty — the diff script handles missing files.
HEAD_SHA=$(git rev-parse HEAD)
BASE_SHA="${{ steps.base.outputs.sha }}"
if [ "$BASE_SHA" = "$HEAD_SHA" ]; then
echo "Base SHA == HEAD SHA, skipping base scan."
echo '[]' > .lint-reports/base/ruff.json
echo '[]' > .lint-reports/base/ty.json
else
git worktree add --detach /tmp/lint-base "$BASE_SHA"
(
cd /tmp/lint-base
ruff check --output-format json --exit-zero \
> "$GITHUB_WORKSPACE/.lint-reports/base/ruff.json" || true
ty check --output-format gitlab --exit-zero \
> "$GITHUB_WORKSPACE/.lint-reports/base/ty.json" || true
)
git worktree remove --force /tmp/lint-base
fi
echo "base ruff: $(wc -c < .lint-reports/base/ruff.json) bytes"
echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes"
- name: Generate diff summary
run: |
python scripts/lint_diff.py \
--base-ruff .lint-reports/base/ruff.json \
--head-ruff .lint-reports/head/ruff.json \
--base-ty .lint-reports/base/ty.json \
--head-ty .lint-reports/head/ty.json \
--base-ref "${{ steps.base.outputs.ref }}" \
--head-ref "${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
--output .lint-reports/summary.md
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload reports as artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: lint-reports
path: .lint-reports/
retention-days: 14
- name: Post / update PR comment
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('.lint-reports/summary.md', 'utf8');
const marker = '<!-- lint-diff-summary -->';
const fullBody = marker + '\n' + body;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: fullBody,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: fullBody,
});
}

View File

@@ -158,19 +158,11 @@ unknown-argument = "warn"
redundant-cast = "ignore"
[tool.ty.src]
exclude = ["**"]
[[tool.ty.overrides]]
include = ["**"]
[tool.ty.overrides.rules]
unresolved-import = "ignore"
invalid-method-override = "ignore"
invalid-assignment = "ignore"
not-iterable = "ignore"
exclude = ["tinker-atropos"]
[tool.ruff]
exclude = ["*"]
exclude = ["tinker-atropos"]
select = ["ALL"]
[tool.uv]
exclude-newer = "7 days"

207
scripts/lint_diff.py Executable file
View File

@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Diff ruff + ty diagnostic reports between two git refs.
Produces a Markdown summary suitable for `$GITHUB_STEP_SUMMARY` and for PR
comments. Compares issues by a stable key (file, rule, line) so line-only
shifts from unrelated edits are treated as the same issue.
Usage:
lint_diff.py \\
--base-ruff base/ruff.json --head-ruff head/ruff.json \\
--base-ty base/ty.json --head-ty head/ty.json \\
[--base-ref origin/main] [--head-ref HEAD]
Any of the four --{base,head}-{ruff,ty} files may be missing or empty; in that
case the tool treats it as "0 diagnostics" (e.g. if base/main doesn't have the
config yet, or a tool crashed).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections import Counter
from pathlib import Path
def _load_json(path: Path | None) -> list[dict]:
if path is None or not path.exists() or path.stat().st_size == 0:
return []
try:
data = json.loads(path.read_text())
except json.JSONDecodeError as exc:
print(f"warning: could not parse {path}: {exc}", file=sys.stderr)
return []
if not isinstance(data, list):
return []
return data
def _normalize_ruff(entries: list[dict]) -> list[dict]:
"""Ruff JSON: {code, filename, location.row, message}."""
out: list[dict] = []
for e in entries:
code = e.get("code") or "unknown"
# ruff emits absolute paths; relativize to repo root if possible
filename = e.get("filename", "")
try:
filename = os.path.relpath(filename)
except ValueError:
pass
line = (e.get("location") or {}).get("row", 0)
out.append(
{
"tool": "ruff",
"rule": code,
"path": filename,
"line": line,
"message": e.get("message", ""),
}
)
return out
def _normalize_ty(entries: list[dict]) -> list[dict]:
"""ty gitlab JSON: {check_name, location.path, location.positions.begin.line, description}."""
out: list[dict] = []
for e in entries:
loc = e.get("location") or {}
begin = (loc.get("positions") or {}).get("begin") or {}
out.append(
{
"tool": "ty",
"rule": e.get("check_name", "unknown"),
"path": loc.get("path", ""),
"line": begin.get("line", 0),
"message": e.get("description", ""),
}
)
return out
def _key(d: dict) -> tuple[str, str, str]:
"""Stable diagnostic identity across commits: (path, rule, message)."""
# Intentionally omit line so unrelated edits above an issue don't flag it
# as "new". Same file + same rule + same message = same issue.
return (d["path"], d["rule"], d["message"])
def _diff(base: list[dict], head: list[dict]) -> tuple[list[dict], list[dict], list[dict]]:
base_map = {_key(d): d for d in base}
head_map = {_key(d): d for d in head}
base_keys = set(base_map)
head_keys = set(head_map)
new_keys = head_keys - base_keys
fixed_keys = base_keys - head_keys
unchanged_keys = base_keys & head_keys
# Return head entries for new (current line numbers), base entries for fixed
return (
[head_map[k] for k in new_keys],
[base_map[k] for k in fixed_keys],
[head_map[k] for k in unchanged_keys],
)
def _rule_counts(entries: list[dict]) -> list[tuple[str, int]]:
return Counter(e["rule"] for e in entries).most_common()
def _section(title: str, entries: list[dict], limit: int = 25) -> str:
if not entries:
return f"**{title}:** none\n"
lines = [f"**{title} ({len(entries)}):**\n"]
# Group by rule for readability
counts = _rule_counts(entries)
lines.append("| Rule | Count |")
lines.append("| --- | ---: |")
for rule, count in counts[:15]:
lines.append(f"| `{rule}` | {count} |")
if len(counts) > 15:
lines.append(f"| _+{len(counts) - 15} more rules_ | |")
lines.append("")
lines.append("<details><summary>First entries</summary>\n")
lines.append("```")
for e in entries[:limit]:
lines.append(f"{e['path']}:{e['line']}: [{e['rule']}] {e['message']}")
if len(entries) > limit:
lines.append(f"... and {len(entries) - limit} more")
lines.append("```")
lines.append("</details>\n")
return "\n".join(lines)
def _tool_report(
tool_name: str,
base: list[dict],
head: list[dict],
base_available: bool,
) -> str:
new, fixed, unchanged = _diff(base, head)
delta = len(head) - len(base)
delta_str = f"+{delta}" if delta > 0 else str(delta)
emoji = "🆕" if delta > 0 else ("" if delta < 0 else "")
lines = [f"## {tool_name}\n"]
if not base_available:
lines.append(
"_Base report unavailable (likely main has no config for this tool yet); "
"treating all head diagnostics as new._\n"
)
lines.append(
f"**Total:** {len(head)} on HEAD, {len(base)} on base "
f"({emoji} {delta_str})\n"
)
lines.append(_section("🆕 New issues", new))
lines.append(_section("✅ Fixed issues", fixed))
lines.append(
f"**Unchanged:** {len(unchanged)} pre-existing issues carried over.\n"
)
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base-ruff", type=Path, required=True)
ap.add_argument("--head-ruff", type=Path, required=True)
ap.add_argument("--base-ty", type=Path, required=True)
ap.add_argument("--head-ty", type=Path, required=True)
ap.add_argument("--base-ref", default="base")
ap.add_argument("--head-ref", default="HEAD")
ap.add_argument(
"--output", type=Path, help="Write summary to this file instead of stdout"
)
args = ap.parse_args()
base_ruff_raw = _load_json(args.base_ruff)
head_ruff_raw = _load_json(args.head_ruff)
base_ty_raw = _load_json(args.base_ty)
head_ty_raw = _load_json(args.head_ty)
base_ruff = _normalize_ruff(base_ruff_raw)
head_ruff = _normalize_ruff(head_ruff_raw)
base_ty = _normalize_ty(base_ty_raw)
head_ty = _normalize_ty(head_ty_raw)
base_ruff_avail = args.base_ruff.exists() and args.base_ruff.stat().st_size > 0
base_ty_avail = args.base_ty.exists() and args.base_ty.stat().st_size > 0
buf: list[str] = []
buf.append(f"# 🔎 Lint report: `{args.head_ref}` vs `{args.base_ref}`\n")
buf.append(_tool_report("ruff", base_ruff, head_ruff, base_ruff_avail))
buf.append(_tool_report("ty (type checker)", base_ty, head_ty, base_ty_avail))
buf.append(
"_Diagnostics are surfaced as warnings — this check never fails the build._\n"
)
summary = "\n".join(buf)
if args.output:
args.output.write_text(summary)
else:
print(summary)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -391,6 +391,99 @@ def test_slash_exec_rejects_skill_commands(server):
assert "skill command" in resp["error"]["message"]
def test_slash_exec_handles_plugin_commands_in_live_gateway(server):
"""Plugin slash commands return normal slash.exec output without using the worker."""
sid = "test-session"
class Worker:
def __init__(self):
self.calls = []
def run(self, cmd):
self.calls.append(cmd)
return f"worker:{cmd}"
worker = Worker()
server._sessions[sid] = {"session_key": sid, "agent": None, "slash_worker": worker}
with patch(
"hermes_cli.plugins.get_plugin_command_handler",
lambda name: (lambda arg: f"plugin:{arg}") if name == "plugin-cmd" else None,
):
resp = server.handle_request({
"id": "r-plugin-slash",
"method": "slash.exec",
"params": {"command": "plugin-cmd hello", "session_id": sid},
})
assert "error" not in resp
assert resp["result"] == {"output": "plugin:hello"}
assert worker.calls == []
def test_slash_exec_plugin_lookup_failure_falls_back_to_worker(server):
"""Plugin discovery failures must not break ordinary slash-worker commands."""
sid = "test-session"
class Worker:
def __init__(self):
self.calls = []
def run(self, cmd):
self.calls.append(cmd)
return f"worker:{cmd}"
worker = Worker()
server._sessions[sid] = {"session_key": sid, "agent": None, "slash_worker": worker}
with patch(
"hermes_cli.plugins.get_plugin_command_handler",
side_effect=RuntimeError("discovery boom"),
):
resp = server.handle_request({
"id": "r-plugin-lookup-failure",
"method": "slash.exec",
"params": {"command": "help", "session_id": sid},
})
assert "error" not in resp
assert resp["result"] == {"output": "worker:help"}
assert worker.calls == ["help"]
def test_slash_exec_plugin_handler_error_returns_output(server):
"""Plugin handler failures return slash output so the TUI does not redispatch."""
sid = "test-session"
class Worker:
def __init__(self):
self.calls = []
def run(self, cmd):
self.calls.append(cmd)
return f"worker:{cmd}"
def handler(arg):
raise RuntimeError(f"handler boom: {arg}")
worker = Worker()
server._sessions[sid] = {"session_key": sid, "agent": None, "slash_worker": worker}
with patch(
"hermes_cli.plugins.get_plugin_command_handler",
lambda name: handler if name == "plugin-cmd" else None,
):
resp = server.handle_request({
"id": "r-plugin-handler-error",
"method": "slash.exec",
"params": {"command": "plugin-cmd hello", "session_id": sid},
})
assert "error" not in resp
assert resp["result"] == {"output": "Plugin command error: handler boom: hello"}
assert worker.calls == []
@pytest.mark.parametrize("cmd", ["retry", "queue hello", "q hello", "steer fix the test", "plan"])
def test_slash_exec_rejects_pending_input_commands(server, cmd):
"""slash.exec must reject commands that use _pending_input in the CLI."""

View File

@@ -5171,9 +5171,13 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4004, "empty command")
# Skill slash commands and _pending_input commands must NOT go through the
# slash worker — see _PENDING_INPUT_COMMANDS definition above.
_cmd_parts = cmd.split() if not cmd.startswith("/") else cmd.lstrip("/").split()
_cmd_base = _cmd_parts[0] if _cmd_parts else ""
# slash worker — see _PENDING_INPUT_COMMANDS definition above. Plugin
# commands must also avoid the worker, but unlike skills/pending-input they
# still return normal slash.exec output so the TUI keeps the pager path.
_cmd_text = cmd.lstrip("/") if cmd.startswith("/") else cmd
_cmd_parts = _cmd_text.split(maxsplit=1)
_cmd_base = (_cmd_parts[0] if _cmd_parts else "").lower()
_cmd_arg = _cmd_parts[1] if len(_cmd_parts) > 1 else ""
if _cmd_base in _PENDING_INPUT_COMMANDS:
return _err(
@@ -5191,6 +5195,27 @@ def _(rid, params: dict) -> dict:
except Exception:
pass
plugin_handler = None
resolve_plugin_command_result = None
if _cmd_base:
try:
from hermes_cli.plugins import (
get_plugin_command_handler,
resolve_plugin_command_result,
)
plugin_handler = get_plugin_command_handler(_cmd_base)
except Exception:
plugin_handler = None
resolve_plugin_command_result = None
if plugin_handler and resolve_plugin_command_result:
try:
result = resolve_plugin_command_result(plugin_handler(_cmd_arg))
return _ok(rid, {"output": str(result or "(no output)")})
except Exception as e:
return _ok(rid, {"output": f"Plugin command error: {e}"})
worker = session.get("slash_worker")
if not worker:
try:

View File

@@ -1,7 +1,13 @@
import { renderSync } from '@hermes/ink'
import React from 'react'
import { PassThrough } from 'stream'
import { describe, expect, it } from 'vitest'
import { MessageLine } from '../components/messageLine.js'
import { toTranscriptMessages } from '../domain/messages.js'
import { upsert } from '../lib/messages.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
describe('toTranscriptMessages', () => {
it('preserves assistant tool-call rows so resume does not drop prior turns', () => {
@@ -21,6 +27,50 @@ describe('toTranscriptMessages', () => {
})
})
describe('MessageLine', () => {
it('preserves a separator after compound user prompt glyphs in transcript rows', () => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const t = {
...DEFAULT_THEME,
brand: { ...DEFAULT_THEME.brand, prompt: 'Ψ >' }
}
const instance = renderSync(
React.createElement(MessageLine, {
cols: 80,
msg: { role: 'user', text: 'Okay' },
t
}),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
const renderedLine = stripAnsi(output)
.split('\n')
.find(line => line.includes('Okay'))
expect(renderedLine).toContain('Ψ > Okay')
})
})
describe('upsert', () => {
it('appends when last role differs', () => {
expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2)

View File

@@ -17,6 +17,13 @@ describe('virtual height estimates', () => {
expect(estimatedMsgHeight(msg, 35, { compact: false, details: false })).toBeGreaterThan(5)
})
it('uses compound user prompt width when estimating user message wrapping', () => {
const msg: Msg = { role: 'user', text: 'x'.repeat(21) }
expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: '' })).toBe(3)
expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: 'Ψ >' })).toBe(4)
})
it('includes detail sections when visible', () => {
const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'line 1\nline 2', tools: ['Tool A', 'Tool B'] }

View File

@@ -17,6 +17,7 @@ import type {
import { useGitBranch } from '../hooks/useGitBranch.js'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
import { appendTranscriptMessage } from '../lib/messages.js'
import { composerPromptWidth } from '../lib/inputMetrics.js'
import { isMac } from '../lib/platform.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { terminalParityHints } from '../lib/terminalParity.js'
@@ -244,7 +245,8 @@ export function useMainApp(gw: GatewayClient) {
}, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections])
const detailsVisible = detailsLayoutKey !== 'hidden:hidden'
const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`
const userPromptWidth = composerPromptWidth(ui.theme.brand.prompt)
const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${userPromptWidth}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`
const heightCache = useMemo(() => {
let cache = heightCachesRef.current.get(heightCacheKey)
@@ -266,9 +268,10 @@ export function useMainApp(gw: GatewayClient) {
estimatedMsgHeight(virtualRows[index]!.msg, cols, {
compact: ui.compact,
details: detailsVisible,
limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS
limitHistory: index < virtualRows.length - FULL_RENDER_TAIL_ITEMS,
userPrompt: ui.theme.brand.prompt
}),
[cols, detailsVisible, ui.compact, virtualRows]
[cols, detailsVisible, ui.compact, ui.theme.brand.prompt, virtualRows]
)
const syncHeightCache = useCallback(

View File

@@ -5,6 +5,7 @@ import { LONG_MSG } from '../config/limits.js'
import { sectionMode } from '../domain/details.js'
import { userDisplay } from '../domain/messages.js'
import { ROLE } from '../domain/roles.js'
import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js'
import {
boundedHistoryRenderText,
boundedLiveRenderText,
@@ -95,6 +96,7 @@ export const MessageLine = memo(function MessageLine({
}
const { body, glyph, prefix } = ROLE[msg.role](t)
const gutterWidth = transcriptGutterWidth(msg.role, t.brand.prompt)
const showDetails =
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking))
@@ -163,13 +165,13 @@ export const MessageLine = memo(function MessageLine({
)}
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={3}>
<NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>
<Text bold={msg.role === 'user'} color={prefix}>
{glyph}{' '}
</Text>
</NoSelect>
<Box width={Math.max(20, cols - 5)}>{content}</Box>
<Box width={transcriptBodyWidth(cols, msg.role, t.brand.prompt)}>{content}</Box>
</Box>
</Box>
)

View File

@@ -1,5 +1,7 @@
import { stringWidth } from '@hermes/ink'
import type { Role } from '../types.js'
export const COMPOSER_PROMPT_GAP_WIDTH = 1
let _seg: Intl.Segmenter | null = null
@@ -162,6 +164,14 @@ export function composerPromptWidth(promptText: string) {
return Math.max(1, stringWidth(promptText)) + COMPOSER_PROMPT_GAP_WIDTH
}
export function transcriptGutterWidth(role: Role, userPrompt: string) {
return role === 'user' ? composerPromptWidth(userPrompt) : 3
}
export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string) {
return Math.max(20, totalCols - transcriptGutterWidth(role, userPrompt) - 2)
}
export function stableComposerColumns(totalCols: number, promptWidth: number) {
// Physical render/wrap width. Always reserve outer composer padding and
// prompt prefix. Only reserve the transcript scrollbar gutter when the

View File

@@ -1,5 +1,6 @@
import type { Msg } from '../types.js'
import { transcriptBodyWidth } from './inputMetrics.js'
import { boundedHistoryRenderText } from './text.js'
const hashText = (text: string) => {
@@ -38,7 +39,12 @@ export const wrappedLines = (text: string, width: number) => {
export const estimatedMsgHeight = (
msg: Msg,
cols: number,
{ compact, details, limitHistory = false }: { compact: boolean; details: boolean; limitHistory?: boolean }
{
compact,
details,
limitHistory = false,
userPrompt = ''
}: { compact: boolean; details: boolean; limitHistory?: boolean; userPrompt?: string }
) => {
if (msg.kind === 'intro') {
return msg.info?.version ? 9 : 5
@@ -56,7 +62,7 @@ export const estimatedMsgHeight = (
return Math.max(2, msg.todos.length + 2)
}
const bodyWidth = Math.max(20, cols - 5)
const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt)
const text = msg.role === 'assistant' && limitHistory ? boundedHistoryRenderText(msg.text) : msg.text
let h = wrappedLines(text || ' ', bodyWidth)