이슈 #2 구현 #3
@@ -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
|
||||
```
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 데이터 모델
|
||||
|
||||
## input-display-state
|
||||
|
||||
초기 구현의 상태는 `ChatApp.submitted_inputs`와 화면 위젯으로 구성한다.
|
||||
|
||||
- `Input.value`: 현재 입력창에 편집 중인 문자열이다.
|
||||
- `submitted_inputs`: 현재 애플리케이션 세션에서 제출된 문자열의 순서 있는 목록이다.
|
||||
- `#conversation`: 제출된 각 문자열을 `Label`로 표시하는 세션 내 대화 영역이다.
|
||||
|
||||
입력 제출이 성공하면 원문을 `submitted_inputs`에 추가하고 같은 문자열을 대화 영역에 표시한 뒤 입력창을 비운다. 빈 문자열은 추가하지 않는다.
|
||||
|
||||
이 모델은 프로세스 메모리에만 존재한다. 파일, 데이터베이스, 외부 서비스에는 기록하지 않으며 애플리케이션 종료 시 상태가 폐기된다. LLM 응답이나 메시지 작성자 구분은 초기 범위에 포함하지 않는다.
|
||||
@@ -0,0 +1,24 @@
|
||||
# 인터페이스 계약
|
||||
|
||||
## tui-input-display
|
||||
|
||||
### 화면
|
||||
|
||||
- Textual 애플리케이션은 헤더, `#conversation` 대화 영역, `#user-input` 입력창을 제공한다.
|
||||
- 애플리케이션 시작 시 입력창에 포커스를 둔다.
|
||||
|
||||
### 입력 제출
|
||||
|
||||
- 사용자가 `#user-input`에서 Enter를 누르면 현재 값이 제출된다.
|
||||
- 비어 있지 않은 값은 변경 없이 대화 영역에 새 항목으로 추가된다.
|
||||
- 제출 후 입력창은 빈 값이 된다.
|
||||
- 대화 영역의 항목 순서는 제출 순서와 같다.
|
||||
|
||||
### 종료
|
||||
|
||||
- `Escape`와 `Ctrl+Q`는 애플리케이션의 종료 동작을 호출한다.
|
||||
- 종료 시 세션 내 메모리 상태를 영구 저장하지 않는다.
|
||||
|
||||
### 경계
|
||||
|
||||
이 인터페이스는 화면 표시와 입력 상태 관리만 담당한다. LLM 클라이언트, 네트워크 요청, 인증 및 외부 서비스 연동은 제공하지 않는다.
|
||||
@@ -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가 실행되고 입력창에 포커스가 배치된다.
|
||||
- 두 번 이상 제출한 입력이 제출 순서대로 모두 표시된다.
|
||||
- 빈 입력은 대화 기록에 추가되지 않는다.
|
||||
- 자동 테스트가 입력 누적 표시와 종료 요청을 검증한다.
|
||||
@@ -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 호출이나 외부 네트워크 요청이 없다. 애플리케이션 종료 뒤 세션 목록과 화면 항목은 보존되지 않는다.
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Textual 기반 대화형 TUI 패키지."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
"""python -m chat_tui 실행 진입점."""
|
||||
|
||||
from .app import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user