code-agent: implement issue 2

Approval-Correlation: 49036599e5df8e265ad6146109634e40
Plan-SHA256: 8b8371814cb3da5d7a9f2136053f543d1f524824ce1d3f1fe307cfe56c583784
Base-SHA: d2f8326a1a
This commit is contained in:
code-agent
2000-01-01 00:00:00 +00:00
parent d2f8326a1a
commit cb53b4b98f
11 changed files with 273 additions and 0 deletions
+23
View File
@@ -1,2 +1,25 @@
# gitea-agent-testbed
Python 3.11 이상에서 실행하는 Textual 기반 대화형 TUI 스캐폴드입니다. 입력한 문자열을 세션 내 대화 영역에 누적 표시하며, LLM이나 외부 API는 호출하지 않습니다.
## 설치
```bash
python -m pip install -e '.[test]'
```
## 실행
```bash
chat-tui
# 또는
python -m chat_tui
```
입력창에서 메시지를 입력하고 Enter를 누르면 대화 영역에 표시됩니다. `Escape` 또는 `Ctrl+Q`로 종료합니다.
## 테스트
```bash
python -m unittest discover -s tests -v
```
+13
View File
@@ -0,0 +1,13 @@
# 데이터 모델
## input-display-state
초기 구현의 상태는 `ChatApp.submitted_inputs`와 화면 위젯으로 구성한다.
- `Input.value`: 현재 입력창에 편집 중인 문자열이다.
- `submitted_inputs`: 현재 애플리케이션 세션에서 제출된 문자열의 순서 있는 목록이다.
- `#conversation`: 제출된 각 문자열을 `Label`로 표시하는 세션 내 대화 영역이다.
입력 제출이 성공하면 원문을 `submitted_inputs`에 추가하고 같은 문자열을 대화 영역에 표시한 뒤 입력창을 비운다. 빈 문자열은 추가하지 않는다.
이 모델은 프로세스 메모리에만 존재한다. 파일, 데이터베이스, 외부 서비스에는 기록하지 않으며 애플리케이션 종료 시 상태가 폐기된다. LLM 응답이나 메시지 작성자 구분은 초기 범위에 포함하지 않는다.
+24
View File
@@ -0,0 +1,24 @@
# 인터페이스 계약
## tui-input-display
### 화면
- Textual 애플리케이션은 헤더, `#conversation` 대화 영역, `#user-input` 입력창을 제공한다.
- 애플리케이션 시작 시 입력창에 포커스를 둔다.
### 입력 제출
- 사용자가 `#user-input`에서 Enter를 누르면 현재 값이 제출된다.
- 비어 있지 않은 값은 변경 없이 대화 영역에 새 항목으로 추가된다.
- 제출 후 입력창은 빈 값이 된다.
- 대화 영역의 항목 순서는 제출 순서와 같다.
### 종료
- `Escape`와 `Ctrl+Q`는 애플리케이션의 종료 동작을 호출한다.
- 종료 시 세션 내 메모리 상태를 영구 저장하지 않는다.
### 경계
이 인터페이스는 화면 표시와 입력 상태 관리만 담당한다. LLM 클라이언트, 네트워크 요청, 인증 및 외부 서비스 연동은 제공하지 않는다.
+23
View File
@@ -0,0 +1,23 @@
# 프로젝트 구조
```text
.
├── pyproject.toml
├── README.md
├── src/
│ └── chat_tui/
│ ├── __init__.py
│ ├── __main__.py
│ └── app.py
├── tests/
│ └── test_app.py
└── docs/
├── data-model.md
├── interface-contract.md
├── project-structure.md
├── requirements/
│ └── req-001_project-scaffold.md
└── runtime-flow.md
```
`pyproject.toml`은 Python 3.11 이상, Textual 런타임 의존성, 선택적 테스트 의존성, 콘솔 진입점을 선언한다. `src/chat_tui/app.py`는 Textual 애플리케이션과 실행 함수를 제공하고, `tests/test_app.py`는 입력 표시와 종료 계약을 검증한다.
@@ -0,0 +1,26 @@
# 프로젝트 스캐폴드 요구사항
## 범위
Python 3.11 이상과 표준 `pyproject.toml`을 사용하는 Textual 기반 TUI 초기 스캐폴드를 제공한다.
## 기능 요구사항
1. `chat-tui` 콘솔 명령과 `python -m chat_tui` 실행을 지원한다.
2. Textual 화면에 사용자 입력창과 대화 영역을 표시한다.
3. 사용자가 입력을 제출하면 입력한 문자열을 세션 내 대화 영역에 순서대로 누적한다.
4. 제출 뒤 입력창을 비운다.
5. `Escape` 또는 `Ctrl+Q`로 애플리케이션 종료를 요청할 수 있다.
6. 입력 표시와 종료 동작을 자동 테스트로 검증한다.
## 제외 범위
LLM 연동, 응답 생성, 외부 API 호출, 인증, 영구 대화 기록 저장은 구현하지 않는다.
## 수용 기준
- Python 3.11 이상 환경에서 의존성을 설치할 수 있다.
- TUI가 실행되고 입력창에 포커스가 배치된다.
- 두 번 이상 제출한 입력이 제출 순서대로 모두 표시된다.
- 빈 입력은 대화 기록에 추가되지 않는다.
- 자동 테스트가 입력 누적 표시와 종료 요청을 검증한다.
+13
View File
@@ -0,0 +1,13 @@
# 실행 흐름
## tui-input-loop
1. `chat-tui` 또는 `python -m chat_tui`가 `main()`을 호출한다.
2. `ChatApp`이 Textual 이벤트 루프를 시작하고 화면을 구성한다.
3. 입력창에 포커스를 둔 뒤 사용자의 입력을 기다린다.
4. 사용자가 Enter를 누르면 `Input.Submitted` 이벤트를 처리한다.
5. 비어 있지 않은 입력을 세션 목록에 추가하고 같은 값을 대화 영역의 새 `Label`로 마운트한다.
6. 입력창을 비우고 대화 영역을 마지막 항목으로 스크롤한 뒤 다시 입력을 기다린다.
7. 사용자가 `Escape` 또는 `Ctrl+Q`를 누르면 Textual 종료 동작을 호출한다.
이 흐름에는 LLM 호출이나 외부 네트워크 요청이 없다. 애플리케이션 종료 뒤 세션 목록과 화면 항목은 보존되지 않는다.
+28
View File
@@ -0,0 +1,28 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "gitea-agent-testbed"
version = "0.1.0"
description = "Textual 기반 대화형 TUI 스캐폴드"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"textual>=0.47,<1.0",
]
[project.optional-dependencies]
test = [
"pytest>=8.0",
]
[project.scripts]
chat-tui = "chat_tui.app:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
+4
View File
@@ -0,0 +1,4 @@
"""Textual 기반 대화형 TUI 패키지."""
__all__ = ["__version__"]
__version__ = "0.1.0"
+7
View File
@@ -0,0 +1,7 @@
"""python -m chat_tui 실행 진입점."""
from .app import main
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
"""사용자 입력을 세션 내 대화 영역에 표시하는 Textual 애플리케이션."""
from textual.app import App, ComposeResult
from textual.containers import VerticalScroll
from textual.widgets import Header, Input, Label
class ChatApp(App[None]):
"""LLM 호출 없이 사용자의 제출 입력만 표시하는 초기 TUI."""
CSS = """
Screen {
layout: vertical;
}
#conversation {
height: 1fr;
border: round $accent;
padding: 1;
}
#user-input {
margin: 1 0;
}
"""
BINDINGS = [
("ctrl+q", "quit", "종료"),
("escape", "quit", "종료"),
]
def __init__(self) -> None:
super().__init__()
self.submitted_inputs: list[str] = []
def compose(self) -> ComposeResult:
yield Header(show_clock=False)
yield VerticalScroll(id="conversation")
yield Input(
placeholder="메시지를 입력하고 Enter를 누르세요.",
id="user-input",
)
def on_mount(self) -> None:
self.query_one("#user-input", Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""제출된 입력을 기록하고 대화 영역에 누적 표시한다."""
value = event.value
if not value:
return
self.submitted_inputs.append(value)
conversation = self.query_one("#conversation", VerticalScroll)
conversation.mount(Label(value, classes="user-message"))
event.input.value = ""
conversation.scroll_end(animate=False)
def main() -> None:
"""콘솔 스크립트의 실행 진입점."""
ChatApp().run()
if __name__ == "__main__":
main()
+46
View File
@@ -0,0 +1,46 @@
import unittest
from unittest.mock import patch
from textual.widgets import Input, Label
from chat_tui.app import ChatApp
class ChatAppTest(unittest.IsolatedAsyncioTestCase):
async def test_submitted_input_is_displayed_and_accumulated(self) -> None:
app = ChatApp()
async with app.run_test() as pilot:
input_widget = app.query_one("#user-input", Input)
input_widget.value = "첫 번째 입력"
await pilot.press("enter")
await pilot.pause()
input_widget.value = "두 번째 입력"
await pilot.press("enter")
await pilot.pause()
self.assertEqual(
app.submitted_inputs,
["첫 번째 입력", "두 번째 입력"],
)
displayed = [label.renderable for label in app.query("#conversation Label")]
self.assertEqual(displayed, ["첫 번째 입력", "두 번째 입력"])
self.assertEqual(input_widget.value, "")
async def test_empty_input_is_not_added(self) -> None:
app = ChatApp()
async with app.run_test() as pilot:
await pilot.press("enter")
await pilot.pause()
self.assertEqual(app.submitted_inputs, [])
self.assertEqual(len(app.query("#conversation Label")), 0)
async def test_escape_requests_application_exit(self) -> None:
app = ChatApp()
with patch.object(app, "exit") as exit_mock:
async with app.run_test() as pilot:
await pilot.press("escape")
await pilot.pause()
exit_mock.assert_called_once_with()